diff --git a/.editorconfig b/.editorconfig index 2c8b872..ba59a21 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,6 +10,12 @@ trim_trailing_whitespace = true indent_style = space indent_size = 4 +[*.cs] +# Keep service APIs instance-based even when a current method has no instance state. +dotnet_diagnostic.CA1822.severity = none +# Prefer interface-shaped collection boundaries over analyzer-suggested concrete types. +dotnet_diagnostic.CA1859.severity = none + [*.md] end_of_line = lf trim_trailing_whitespace = false diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml new file mode 100644 index 0000000..ef687fe --- /dev/null +++ b/.github/workflows/windows-build.yml @@ -0,0 +1,48 @@ +name: Windows build + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + runs-on: windows-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore .\ColumnPadStudio.sln + + - name: Check formatting + run: dotnet format .\ColumnPadStudio.sln --no-restore --verify-no-changes + + - name: Build + run: dotnet build .\ColumnPadStudio.sln -c Release --no-restore + + - name: Run domain checks + run: dotnet run --project .\tests\ColumnPadStudio.Domain.Tests\ColumnPadStudio.Domain.Tests.csproj -c Release --no-build + + - name: Run app smoke checks + run: dotnet run --project .\tests\ColumnPadStudio.SmokeTests\ColumnPadStudio.SmokeTests.csproj -c Release --no-build + + - name: Verify single-file publish + shell: pwsh + run: | + dotnet publish .\src\ColumnPadStudio\ColumnPadStudio.csproj -p:PublishProfile=FolderProfile + $files = @(Get-ChildItem .\src\ColumnPadStudio\publish -File) + if ($files.Count -ne 1 -or $files[0].Name -ne 'ColumnPadStudio.exe') { + throw "Expected publish output to contain only ColumnPadStudio.exe." + } diff --git a/CHANGELOG.md b/CHANGELOG.md index d5e4cf8..c453634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ All notable changes to this project are documented in this file. ## [Unreleased] +## [v2.5.0] - 2026-08-07 + +### Added +- Added saved Standard (320 px), Custom (220-5000 px), and Fit Columns to Window sizing, plus one global Snap control and adjustable spacing. +- Added a saved workspace-wide line-number gutter width from 32-160 px. +- Added theme-aware per-column text colours with presets, custom hexadecimal colours, and layout persistence. +- Added Ruled, Soft Ruled, and Strong Ruled paper styles aligned with the editor and gutter. +- Added an automated Windows build gate and explicit version signatures for saved and exported files. + +### Changed +- Replaced Markdown document/export support with concise, readable JSON text exports; native `.columnpad.json` layouts remain the full-fidelity format. +- Embedded picture data in native layouts so saved workspaces remain portable if an original picture is moved or deleted. +- Moved the app, tests, automated build, and one-file release profile to the supported .NET 10 LTS runtime. +- Reworked recovery into complete atomic generations with fallback, and expanded workspace dirty-state tracking to include tab and session changes. +- Simplified Workflow Builder creation, connection editing, save/import/export actions, and automatic node placement while keeping older workflows compatible. + +### Fixed +- Fixed column adding and resetting so pixel widths remain stable, horizontal workspace scrolling returns when needed, and long text scrolls inside its own column. +- Made Snap change only the shared gap and made Fit an explicit equal-width mode that preserves saved pixel widths. +- Fixed selection highlighting, keyboard-focus borders, paper alignment, gutter updates, and repeated blank lines during paste. +- Fixed save, exit, session-replacement, and crash-recovery paths so unsaved work and the previous healthy recovery generation are preserved. +- Prevented text and JSON exports from silently discarding pictures or rich column formatting by requiring a native layout save. +- Added stable workflow node IDs and migration for older step-list workflows. +- Quarantined malformed preferences, bounded crash-log growth, and strengthened layout, workflow, image, and recovery validation. + +### Tested +- Added coverage for column spacing preferences, text-colour persistence, paper alignment and compatibility, selection contrast, keyboard focus, preference quarantine, transactional recovery, portable pictures, schema validation, migration, and import limits. +- Completed the Release build with no warnings or errors, passed 51 domain checks and 558 app smoke checks, and launch-checked the single-file Windows executable. + ## [v2.4.1] - 2026-08-06 ### Windows Build diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..a63a7d2 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + latest-recommended + true + true + + diff --git a/README.md b/README.md index b1e9068..16d830d 100644 --- a/README.md +++ b/README.md @@ -3,72 +3,89 @@ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) ![Platform: Windows](https://img.shields.io/badge/platform-Windows-0078D6) -ColumnPad is a Windows writing app for notes, plans, prompts, checklists, and structured text. It gives each idea a clean, side-by-side column while keeping workspaces local, recoverable, and easy to export. +ColumnPad is a Windows writing app for notes, plans, prompts, checklists, and structured text. It keeps ideas separated in clean side-by-side columns while workspaces remain local, recoverable, and easy to export. ## Project Status -Active development. Current release: **v2.4.1**. +Active development. Current release: **v2.5.0**. -Release notes: [v2.4.1](docs/releases/v2.4.1.md). +Release notes: [v2.5.0](docs/releases/v2.5.0.md). ColumnPad is a portable Windows desktop app. It does not require an account, cloud storage, or an always-on connection. ## Download and Install -Download the latest `ColumnPadStudio.exe` from the [GitHub Releases page](../../releases/latest), save it in a permanent folder, and run it like a normal Windows application. +Download `ColumnPadStudio.exe` from the [latest GitHub release](../../releases/latest), save it in a permanent folder, and run it like a normal Windows application. -The released executable is self-contained: Visual Studio, Git, and the .NET SDK are not required to use it. +The released executable is self-contained. Visual Studio, Git, and the .NET SDK are not required to use it. -ColumnPad is not code-signed yet. Windows SmartScreen may warn the first time it is opened; download releases only from this repository. +ColumnPad is not code-signed yet, so Windows SmartScreen may warn the first time it is opened. Download releases only from this repository. ## Screenshot ![ColumnPad desktop interface](docs/columnpad-screenshot.png) -The screenshot reflects the current three-column writing surface and contains no personal or sample document data. +The screenshot shows the current three-column writing surface with clean sample content and no personal data. ## Main Features -- Side-by-side writing columns with resize controls and workspace tabs. -- Single-text and column modes for different drafting styles. -- Plain-text, Markdown, native layout, and workspace-session open, save, and export flows. +- Standard 320 px, saved Custom 220-5000 px, and explicit Fit Columns to Window sizing. +- Stable fixed-width columns with automatic main-window left/right scrolling as more columns are added. +- One global Snap All Columns Together setting with an adjustable gap that does not resize columns. +- Per-column resizing, freeze/unfreeze, reset-to-default actions, and independent vertical scrolling for long text. +- Workspace-wide adjustable line-number gutter width from 32-160 px. +- Workspace tabs plus Single Text Mode and Column Mode. +- Plain `.txt`, concise readable `.json`, full-fidelity `.columnpad.json`, and multi-workspace session files. - Auto-recovery, crash logging, and save-before-exit safeguards. -- Line numbers, word wrap, spell checking, proofing-language selection, lined-paper mode, and paste helpers for bullets and checklists. +- Line numbers, word wrap, spell checking, proofing-language selection, and paste helpers for bullets and checklists. +- Ruled, Soft Ruled, and Strong Ruled paper styles aligned with the editor and gutter. - Light, dark, and default themes with saved preferences. -- In-column pictures with drag-and-drop placement, proportional resizing, and text layering. -- Workflow Builder templates, node colours, workflow JSON import/export, and readable text or Markdown workflow exports. -- A quiet, best-effort check for a newer stable GitHub release at startup. +- Theme-aware per-column text-colour presets and custom colours. +- In-column pictures with drag-and-drop placement, proportional resizing, text layering, and portable native-layout storage. +- Workflow Builder templates, node colours, connection editing, workflow JSON import/export, and readable text exports. +- A quiet, best-effort check for newer stable GitHub releases at startup. ## Supported Platforms and Technology - Windows 10 or Windows 11, x64. -- C#, .NET 8, and WPF. +- C#, .NET 10 LTS, and WPF. - Self-contained, single-file Windows publishing for releases. -## Privacy and Configuration +## Privacy and Local Storage No account, API key, server, or project-level configuration is required. -ColumnPad keeps preferences, recovery data, workflow-library data, imported image copies, and crash logs in app-managed local application storage. Native layouts retain references to imported images rather than embedding image data, so keep the image copies with a layout when moving it to another computer. +ColumnPad keeps preferences, recovery data, saved workflows, imported image copies, and crash logs in app-managed local application storage. Native layouts embed bounded image data so a saved workspace remains portable if the original imported file is moved or deleted. -At startup, ColumnPad may make a brief request to the public GitHub releases endpoint to check for a newer stable version. The check never blocks writing or startup and does not send document content. +At startup, ColumnPad may make a brief request to the public GitHub releases endpoint to check for a newer stable version. The check never sends document content and never blocks writing or startup. ## Using ColumnPad -1. Create columns or a workspace tab for each topic you want to keep separate. -2. Write directly, use the View and Columns menus to adjust the editing surface, and use the editor menus for search, paste, and checklist actions. -3. Use File commands to open, save, export, or restore text, Markdown, native layouts, and workspace sessions. -4. Open the Workflow Builder from the Workflows menu to start from a template or import a workflow. +1. Create columns or workspace tabs for the topics you want to keep separate. +2. Use **Columns > Column Width** to choose Standard, Custom, or Fit Columns to Window sizing. +3. Use each column's **Actions** menu for rename, move, resize, reset, formatting, pictures, and other column-specific actions. Right-clicking a column header is reserved for renaming. +4. Use the **View** menu to adjust gutters, paper style, themes, wrapping, proofing, and Single Text or Column Mode. +5. Use **File** commands to open, save, export, restore, or print text, JSON, native layouts, and workspace sessions. +6. Open **Workflows** to create, import, edit, and save reusable workflow diagrams. + +## File Formats + +- `.txt` stores normal text documents and readable multi-column text exports. +- `.json` stores concise readable column text exports. +- `.columnpad.json` stores full layouts, including columns, formatting, settings, and embedded pictures. +- `.workflow.json` stores editable Workflow Builder diagrams. + +Markdown document and export support has been retired. Existing Markdown files remain readable in ordinary text editors but are not presented as a ColumnPad file type. ## Building from Source Developer requirements: - Windows 10 or Windows 11. -- .NET 8 SDK or a newer SDK that can build `net8.0-windows`. +- .NET 10 SDK. - Optional: Visual Studio with the .NET Desktop Development workload. -Clone the repository and build: +Clone and build: ```powershell git clone @@ -77,7 +94,7 @@ dotnet restore dotnet build .\ColumnPadStudio.sln -c Release ``` -Run the app from source: +Run from source: ```powershell dotnet run --project .\src\ColumnPadStudio\ColumnPadStudio.csproj -c Release @@ -89,7 +106,7 @@ Publish the portable single-file executable: dotnet publish .\src\ColumnPadStudio\ColumnPadStudio.csproj -p:PublishProfile=FolderProfile ``` -The publish output is `src\ColumnPadStudio\publish\ColumnPadStudio.exe`. +The release output is `src\ColumnPadStudio\publish\ColumnPadStudio.exe` with no loose runtime files beside it. ## Testing @@ -100,29 +117,29 @@ dotnet run --project .\tests\ColumnPadStudio.Domain.Tests\ColumnPadStudio.Domain dotnet run --project .\tests\ColumnPadStudio.SmokeTests\ColumnPadStudio.SmokeTests.csproj -c Release --no-build ``` -Before publishing, also follow [RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md) and the visual checks in [docs/UI_QA_CHECKLIST.md](docs/UI_QA_CHECKLIST.md). +Before publishing, also follow [RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md) and [docs/UI_QA_CHECKLIST.md](docs/UI_QA_CHECKLIST.md). ## Project Structure ```text src/ColumnPadStudio/ WPF app shell, controls, services, resources, and workflows -src/ColumnPadStudio.Domain/ Pure text, list, and workspace rules +src/ColumnPadStudio.Domain/ Reusable text, list, and workspace rules tests/ Domain and app-level smoke checks docs/ Release notes, architecture notes, workflows, screenshots, and QA guidance -tools/ Maintenance and asset-generation helpers +tools/ Maintenance helpers ``` -For a detailed guide, see [docs/REPOSITORY_STRUCTURE.md](docs/REPOSITORY_STRUCTURE.md). Larger changes should follow [docs/APP_BUILDING_STANDARD.md](docs/APP_BUILDING_STANDARD.md). +See [docs/REPOSITORY_STRUCTURE.md](docs/REPOSITORY_STRUCTURE.md) for more detail. Larger changes should follow [docs/APP_BUILDING_STANDARD.md](docs/APP_BUILDING_STANDARD.md). ## Known Limitations -- The portable executable is currently unsigned and has no installer, automatic update, or uninstall flow. -- The app is Windows-only. -- Imported images remain local files referenced by native layouts; moving a layout alone does not package its images. +- The portable executable is unsigned and may trigger a SmartScreen warning. +- ColumnPad is Windows-only and currently has no installer or uninstall entry. +- Update checks notify the user but do not install updates automatically. ## Contributing -Keep changes focused, preserve local-data and secret exclusions, update relevant documentation, and run the Release build plus both test suites before opening a pull request. Add or refresh screenshots only when the visible interface has meaningfully changed. +Keep changes focused, preserve local-data and secret exclusions, update relevant documentation, and run the Release build plus both test suites before opening a pull request. Refresh screenshots only when the visible interface has meaningfully changed. ## License diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 930ca66..1f842d1 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -22,6 +22,7 @@ dotnet build .\ColumnPadStudio.sln -c Release ``` Expected result: +- `0 Warning(s)` - `0 Error(s)` ## 3. Run domain tests @@ -49,7 +50,7 @@ Expected output: - `src\ColumnPadStudio\publish\ColumnPadStudio.exe` - No `.pdb`, `.dll`, `.json`, or loose runtime files should remain beside the EXE for the public release asset. -Note: the publish profile pins the self-contained runtime pack to the cached .NET 8 patch version used for release builds. If this version is changed, restore the matching `win-x64` runtime packs before publishing. +The solution targets .NET 10 LTS. The self-contained publish resolves the latest available .NET 10 patch so the public EXE carries current runtime fixes. ## 6. Manual UI sanity checks Run the fuller UI checklist in `docs\UI_QA_CHECKLIST.md` and check the app-building standard in `docs\APP_BUILDING_STANDARD.md`, then at minimum confirm: @@ -57,12 +58,22 @@ Run the fuller UI checklist in `docs\UI_QA_CHECKLIST.md` and check the app-build 1. Launch `ColumnPadStudio.exe`. 2. Open a saved layout or text document. 3. Confirm the selected theme still persists after closing and reopening the app. -4. Add/remove columns and verify scroll behavior still works. -5. Switch between single text mode and column mode. -6. Open the Workflow Builder and confirm preview/editing still works. -7. Save and reopen a `.columnpad.json` layout. -8. Verify recovery prompt wording is sensible if recovery data exists. -9. Right-click a column header, editor, line gutter, workspace tab, and workflow node; hover nested menu items and confirm hover colour plus text contrast are readable in light, dark, and default themes. +4. Under Columns > Column Width, select Standard and confirm new columns open at 320 px without shrinking existing columns. +5. Select Custom, enter a value from 220-5000 px, add another column, and confirm the new column uses that default while individually resized columns keep their widths. +6. Add enough fixed-width columns to exceed the window and confirm the bottom scrollbar moves the main workspace left and right. +7. Select Fit Columns to Window and confirm columns share the available width equally; switch back to Standard or Custom and confirm saved pixel widths return. +8. Freeze a resized column, then use Reset Selected and Reset All; confirm the affected columns return to the current default width and become unlocked. +9. Turn Snap All Columns Together on and off; confirm only the global gap changes and no column width changes. +10. Change the column gap and confirm existing plus newly added snapped columns follow the setting without shrinking. +11. Paste enough text into one column to overflow it and confirm only that column receives its own vertical scrollbar. +12. Apply per-column text colours and confirm they survive theme changes and layout reload. +13. Change the font size and confirm Ruled, Soft Ruled, and Strong Ruled paper stay aligned with text rows in every theme. +14. Select text, move focus, and confirm active/inactive selection plus keyboard-focus borders remain readable in every theme. +15. Switch between single text mode and column mode. +16. Open the Workflow Builder and confirm preview/editing still works. +17. Save and reopen a `.columnpad.json` layout with pictures and column formatting after moving the original picture file. +18. Verify recovery prompt wording is sensible if recovery data exists. +19. Right-click a column header, editor, line gutter, workspace tab, and workflow node; hover nested menu items and confirm hover colour plus text contrast are readable in light, dark, and default themes. ## 7. Release metadata 1. Update `CHANGELOG.md`. diff --git a/docs/REPOSITORY_STRUCTURE.md b/docs/REPOSITORY_STRUCTURE.md index 41b41e6..69db6ca 100644 --- a/docs/REPOSITORY_STRUCTURE.md +++ b/docs/REPOSITORY_STRUCTURE.md @@ -6,6 +6,7 @@ This repository uses a clean `src / tests / docs / tools` layout so application ```text . +|-- .github/workflows/ |-- src/ | |-- ColumnPadStudio/ | `-- ColumnPadStudio.Domain/ @@ -18,6 +19,8 @@ This repository uses a clean `src / tests / docs / tools` layout so application | |-- releases/ | `-- workflows/ |-- tools/ +|-- Directory.Build.props +|-- global.json |-- ColumnPadStudio.sln |-- README.md |-- CHANGELOG.md @@ -38,26 +41,26 @@ Important areas: Main shell startup/state coordination and column-control construction, sizing, selection, and event wiring. - `MainWindow.FileSession.cs`, `MainWindow.Lifecycle.cs`, `MainWindow.WorkspaceSessions.cs`, `MainWindow.SaveBeforeExit.cs`, and `MainWindow.DestructiveActions.cs` Open/save/export/print commands, recovery/autosave lifecycle, workspace-session JSON handling, exit-save prompts, and destructive-action confirmation wiring. -- `MainWindow.EditorSurface.cs` - Column actions and selected-editor commands. +- `MainWindow.EditorSurface.cs` and `MainWindow.ColumnFormatting.cs` + Column actions, selected-editor commands, and per-column text-colour coordination. - `MainWindow.Search.cs`, `MainWindow.ViewModes.cs`, and `MainWindow.Shortcuts.cs` Search/replace, theme/view mode switching, workflow launch, and keyboard shortcut routing. - `MainWindow.Workspaces.cs` Workspace tab lifecycle and rename wiring. - `Controls/` - Reusable UI controls and dialogs. Large controls are split by responsibility: the column editor keeps image, spelling, paste, gutter, menu, and interaction behavior in named partial files; Workflow Builder keeps canvas interactions and file actions separate from window lifecycle. + Reusable UI controls and dialogs. Large controls are split by responsibility: the column editor keeps image, spelling, paste, gutter, menu, and interaction behavior in named partial files; `PaperBackground` draws paper patterns from the real editor line height; Workflow Builder keeps canvas interactions and file actions separate from window lifecycle. - `Resources/` - Shared WPF resource dictionaries loaded by `App.xaml`. `AppResources.xaml` is only an index. `ThemeBrushes.xaml` contains app brushes, system colour overrides, and shared geometry. `ControlStyles.xaml` contains reusable WPF control templates. `MenuStyles.xaml` contains shared menu and context-menu styling. + Shared WPF resource dictionaries loaded by `App.xaml`. `AppResources.xaml` is the index and owns theme-neutral values. `Themes/` contains one complete palette each for Light, Default, and Dark modes. `ControlStyles.xaml` contains reusable WPF control templates. `MenuStyles.xaml` contains shared menu and context-menu styling. - `ViewModels/` Writable app state for the shell, columns, workflows, and workspace tabs. Larger view models are split into named partial files. `ColumnViewModel.Checklists.cs` and `ColumnViewModel.Images.cs` own rich column behavior. `MainViewModel.TextDocuments.cs`, `MainViewModel.LayoutPersistence.cs`, and `MainViewModel.Persistence.cs` keep text documents, native layouts, and save coordination distinct. - `Services/` - Focused single-job helpers, including `AppStoragePaths` as the single place for app storage folders and `GitHubReleaseUpdateService` for the optional, non-blocking stable-release check. Workflow storage, serialization, and readable text/Markdown exports remain one service split into clearly named partial files so the format has one source of truth. + Focused single-job helpers, including `AppStoragePaths` for app storage folders, `ColumnTextColorService` for validated text-colour values, and `GitHubReleaseUpdateService` for the optional stable-release check. Workflow storage, serialization, and readable text exports remain one service split into clearly named partial files so the format has one source of truth. - `Models/` Small shared file and storage contracts that are used by both view models and services. - `Workflows/` Workflow models and built-in template catalog. - `Assets/` - Icons, splash, wordmark, and app branding. + The compiled Windows icon plus reproducible icon, splash, and wordmark branding sources generated by `tools/Generate-BrandAssets.ps1`. ### `src/ColumnPadStudio.Domain/` Pure rules and parsing helpers with no WPF UI code. @@ -71,7 +74,7 @@ Current sub-areas: Workspace import detection and workspace constraints. ### `tests/ColumnPadStudio.SmokeTests/` -Broad tests for shell-facing behavior like layout save/load, recovery, export/import, and view-model state. +Broad tests for shell-facing behavior like layout save/load, recovery, export/import, view-model state, and live WPF resources. The entry point coordinates focused visual/theme, infrastructure, workflow, and editor-service suites through one shared result context. ### `tests/ColumnPadStudio.Domain.Tests/` Smaller focused tests for domain rules. @@ -92,6 +95,12 @@ Important docs: ### `tools/` Helper scripts that support maintenance or asset generation. +### `.github/workflows/` +Windows CI that checks formatting, builds the Release solution, runs both executable test suites, and verifies the portable publish contains one `ColumnPadStudio.exe`. + +### Root build settings +`global.json` keeps local and automated builds on the .NET 10 LTS SDK line. `Directory.Build.props` applies the recommended analyzers, code-style checks, and warning-as-error policy to every project. + ## Architecture Notes The app is intentionally lightweight: @@ -116,7 +125,7 @@ The app is intentionally lightweight: ## Maintenance Guidance - Keep `bin/`, `obj/`, and `publish/` as generated output only. - Keep app-wide WPF resources in `src/ColumnPadStudio/Resources/`, not directly in `App.xaml`. -- Put new shared colours and theme brushes in `ThemeBrushes.xaml`. +- Put theme-specific colours in the matching file under `Resources/Themes/`, and keep the same resource keys in all three palettes. - Put reusable control templates in `ControlStyles.xaml`. - Put shared menu and right-click dropdown styling in `MenuStyles.xaml`. - Put new app-facing helpers under `src/ColumnPadStudio/Services/` only if they have one clear job. diff --git a/docs/UI_QA_CHECKLIST.md b/docs/UI_QA_CHECKLIST.md index 984ebd2..5ad4803 100644 --- a/docs/UI_QA_CHECKLIST.md +++ b/docs/UI_QA_CHECKLIST.md @@ -14,6 +14,8 @@ Use this before calling a build visually ready. The goal is to catch the rough e - Open right-click menus on column headers, editor text, line gutters, workspace tabs, and workflow nodes. - Hover every nested menu item and confirm the text remains readable, including submenu headers while the submenu is open. - Check that menu hover states use the app theme colour rather than a mismatched Windows-default highlight. +- Select text, move focus to a menu, and confirm both active and inactive selections remain readable. +- Use `Tab` to move through buttons, drop-downs, tabs, checkboxes, lists, and text fields; confirm each focused control uses the same neutral theme border. - Close and reopen the app and confirm the last selected theme is restored. ## Editing @@ -22,18 +24,34 @@ Use this before calling a build visually ready. The goal is to catch the rough e - Check that line numbers stay aligned after paste, delete, undo, and resize. - Switch gutter modes between numbers, bullets, and checklist. - Toggle checklist rows from the gutter and from the context menu. +- Apply preset and custom text colours to separate columns, then switch themes and reopen the saved layout. +- Try Ruled, Soft Ruled, and Strong Ruled paper at several font sizes; confirm the editor and number gutter stay on the same row spacing. - Use `Esc` to clear selected text without disturbing other columns. ## Columns - Add, remove, duplicate, and swap columns. - Try deleting a column with text and confirm the warning is clear. - Try clearing all columns and confirm the destructive warning appears. +- Confirm Standard opens new columns at 320 px without shrinking existing columns. +- Set a Custom default between 220 and 5000 px, add columns, and confirm new columns use it while individually resized columns keep their widths. +- Add enough Standard or Custom columns to exceed the window and confirm the bottom scrollbar moves the main workspace left and right. +- Select Fit Columns to Window and confirm columns share the available width equally; restore Standard or Custom and confirm saved pixel widths return. - Drag column right edges and confirm locked columns cannot be resized. +- Reset a locked selected column and then reset all columns; confirm they return to the current default width and unlock. +- Turn Snap All Columns Together on and off; confirm only the global gap changes, widths stay unchanged, and no individual snap setting exists. +- Change the column gap and confirm existing, newly added, and loaded snapped columns follow it without shrinking. +- Paste enough text to overflow one column and confirm only that column gets its own vertical scrollbar. - Switch between Single Text Mode and Column Mode. +## Pictures +- Drop the same picture into more than one column and confirm every copy renders inside its own column. +- Move and resize pictures at different column widths; confirm resizing stays proportional and does not jitter. +- Switch a picture between in-front-of-text and behind-text placement. +- Save, close, and reopen the layout; confirm picture source, size, position, and layer are preserved. + ## Files - Open a `.txt` file and confirm it opens as a single text document. -- Confirm first save of an opened `.txt` or `.md` asks for Save As. +- Confirm first save of an opened `.txt` document or JSON text export asks for Save As. - Open a native `.columnpad.json` layout and confirm direct Save is available. - Save, close, and reopen a layout. - Open multiple workspace tabs, save a session, close, and reopen it. diff --git a/docs/columnpad-screenshot.png b/docs/columnpad-screenshot.png index 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()) + { + if (item.Tag is string value) + item.IsChecked = string.Equals(value, VM.EditorTextColor, StringComparison.Ordinal); + } + + var isCustom = ColumnTextColorService.IsCustom(VM.EditorTextColor); + ColumnTextColorCustomMenuItem.IsChecked = isCustom; + ColumnTextColorCustomSwatch.Background = VM.CustomEditorTextColorBrush ?? Brushes.Transparent; + ColumnTextColorCustomLabel.Text = isCustom + ? $"Custom... ({VM.EditorTextColor})" + : "Custom..."; + } + private void PastePresetNone_Click(object sender, RoutedEventArgs e) => SetPastePreset(PasteListPreset.None); private void PastePresetBullets_Click(object sender, RoutedEventArgs e) => SetPastePreset(PasteListPreset.Bullets); private void PastePresetChecklist_Click(object sender, RoutedEventArgs e) => SetPastePreset(PasteListPreset.Checklist); @@ -108,24 +159,25 @@ private void ToggleChecklistChecksForSelection() if (VM.LineMarkerMode != LineMarkerMode.Checklist) VM.LineMarkerMode = LineMarkerMode.Checklist; - var (startLine, endLine) = GetSelectedLineRange(); + var (startLine, endLine) = GetSelectedLogicalLineRange(); for (var i = startLine; i <= endLine; i++) VM.ToggleChecklistLineChecked(i); QueueLineNumberRefresh(); } - private (int StartLine, int EndLine) GetSelectedLineRange() + private (int StartLine, int EndLine) GetSelectedLogicalLineRange() { var selectionStart = Editor.SelectionStart; var selectionEnd = selectionStart + Editor.SelectionLength; + var editorText = Editor.Text ?? string.Empty; - var startLine = Editor.GetLineIndexFromCharacterIndex(selectionStart); - var endLine = Editor.GetLineIndexFromCharacterIndex(selectionEnd); + var startLine = GetLogicalLineIndexFromCharacterIndex(selectionStart); + var endLine = GetLogicalLineIndexFromCharacterIndex(selectionEnd); if (selectionEnd > selectionStart && endLine > startLine && - selectionEnd == Editor.GetCharacterIndexFromLineIndex(endLine)) + IsLogicalLineStart(editorText, selectionEnd)) { endLine--; } diff --git a/src/ColumnPadStudio/Controls/ColumnEditorControl.xaml b/src/ColumnPadStudio/Controls/ColumnEditorControl.xaml index 975bac4..1bfee9f 100644 --- a/src/ColumnPadStudio/Controls/ColumnEditorControl.xaml +++ b/src/ColumnPadStudio/Controls/ColumnEditorControl.xaml @@ -2,10 +2,20 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="clr-namespace:ColumnPadStudio.Controls" - MinWidth="220" + xmlns:domain="clr-namespace:ColumnPadStudio.Domain.Workspaces;assembly=ColumnPadStudio.Domain" + MinWidth="{x:Static domain:WorkspaceConstraints.MinimumColumnWidth}" Foreground="{DynamicResource ControlForegroundBrush}" UseLayoutRounding="True" SnapsToDevicePixels="True"> + + + @@ -19,6 +29,7 @@ - + - - - - - - - - - - - - - - - - - - - @@ -87,29 +64,141 @@ Foreground="{DynamicResource LineNumberForegroundBrush}"/> - - - - - @@ -125,25 +214,26 @@ + + - - - @@ -171,20 +261,15 @@ - - - - - + @@ -299,8 +412,8 @@ - - + + @@ -432,6 +545,10 @@ + + + + diff --git a/src/ColumnPadStudio/Controls/ColumnEditorControl.xaml.cs b/src/ColumnPadStudio/Controls/ColumnEditorControl.xaml.cs index f752e2b..56370f1 100644 --- a/src/ColumnPadStudio/Controls/ColumnEditorControl.xaml.cs +++ b/src/ColumnPadStudio/Controls/ColumnEditorControl.xaml.cs @@ -20,9 +20,15 @@ public sealed class ColumnImageFileEventArgs(string filePath, double left, doubl public double Top { get; } = top; } +public sealed class ColumnTextColorEventArgs(string value) : EventArgs +{ + public string Value { get; } = value; +} + public partial class ColumnEditorControl : UserControl { public event EventHandler? EditorFocused; + public event EventHandler? ColumnActionsOpening; public event EventHandler? LockWidthRequested; public event EventHandler? MoveLeftRequested; public event EventHandler? MoveRightRequested; @@ -39,13 +45,24 @@ public partial class ColumnEditorControl : UserControl public event EventHandler? ToggleBoldRequested; public event EventHandler? ToggleItalicRequested; public event EventHandler? ResetFontRequested; + public event EventHandler? SetTextColorRequested; + public event EventHandler? SetCustomTextColorRequested; private ScrollViewer? _editorScrollViewer; private bool _lineNumberRefreshPending; private int _lastRenderedLineNumberCount = -1; + private LineMarkerMode? _lastRenderedLineMarkerMode; + private int _lastRenderedGutterStateVersion = -1; + private int _checklistLayoutVersion; + private int _lastRenderedChecklistLayoutVersion = -1; private int _gutterContextLineIndex = -1; private int _editorContextMenuCharacterIndex = -1; private ColumnViewModel? _observedVm; + private bool _isObservedVmSubscribed; + private bool _hasSavedEditorScrollOffsets; + private bool _editorScrollRestorePending; + private double _savedEditorHorizontalOffset; + private double _savedEditorVerticalOffset; public ColumnEditorControl() { @@ -58,7 +75,6 @@ public ColumnEditorControl() public int SelectionStart => Editor.SelectionStart; public int SelectionLength => Editor.SelectionLength; public double PictureSurfaceWidth => ImageOverlay.ActualWidth; - public double PictureSurfaceHeight => ImageOverlay.ActualHeight; private ColumnViewModel? VM => DataContext as ColumnViewModel; diff --git a/src/ColumnPadStudio/Controls/PaperBackground.cs b/src/ColumnPadStudio/Controls/PaperBackground.cs new file mode 100644 index 0000000..a73f53e --- /dev/null +++ b/src/ColumnPadStudio/Controls/PaperBackground.cs @@ -0,0 +1,112 @@ +using ColumnPadStudio.Models; +using System.Windows; +using System.Windows.Media; + +namespace ColumnPadStudio.Controls; + +public sealed class PaperBackground : FrameworkElement +{ + public static readonly DependencyProperty BaseBackgroundProperty = DependencyProperty.Register( + nameof(BaseBackground), + typeof(Brush), + typeof(PaperBackground), + new FrameworkPropertyMetadata(Brushes.Transparent, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty PatternBrushProperty = DependencyProperty.Register( + nameof(PatternBrush), + typeof(Brush), + typeof(PaperBackground), + new FrameworkPropertyMetadata(Brushes.Transparent, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty IsPaperEnabledProperty = DependencyProperty.Register( + nameof(IsPaperEnabled), + typeof(bool), + typeof(PaperBackground), + new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty PaperStyleProperty = DependencyProperty.Register( + nameof(PaperStyle), + typeof(PaperStyle), + typeof(PaperBackground), + new FrameworkPropertyMetadata(PaperStyle.Ruled, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty LineHeightProperty = DependencyProperty.Register( + nameof(LineHeight), + typeof(double), + typeof(PaperBackground), + new FrameworkPropertyMetadata(23.0, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty VerticalOffsetProperty = DependencyProperty.Register( + nameof(VerticalOffset), + typeof(double), + typeof(PaperBackground), + new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsRender)); + + public Brush BaseBackground + { + get => (Brush)GetValue(BaseBackgroundProperty); + set => SetValue(BaseBackgroundProperty, value); + } + + public Brush PatternBrush + { + get => (Brush)GetValue(PatternBrushProperty); + set => SetValue(PatternBrushProperty, value); + } + + public bool IsPaperEnabled + { + get => (bool)GetValue(IsPaperEnabledProperty); + set => SetValue(IsPaperEnabledProperty, value); + } + + public PaperStyle PaperStyle + { + get => (PaperStyle)GetValue(PaperStyleProperty); + set => SetValue(PaperStyleProperty, value); + } + + public double LineHeight + { + get => (double)GetValue(LineHeightProperty); + set => SetValue(LineHeightProperty, value); + } + + public double VerticalOffset + { + get => (double)GetValue(VerticalOffsetProperty); + set => SetValue(VerticalOffsetProperty, value); + } + + protected override void OnRender(DrawingContext drawingContext) + { + base.OnRender(drawingContext); + drawingContext.DrawRectangle(BaseBackground, null, new Rect(RenderSize)); + + if (!IsPaperEnabled || ActualWidth <= 0 || ActualHeight <= 0) + return; + + var spacing = double.IsFinite(LineHeight) ? Math.Max(8.0, LineHeight) : 23.0; + var style = Enum.IsDefined(PaperStyle) ? PaperStyle : PaperStyle.Ruled; + var verticalOffset = double.IsFinite(VerticalOffset) ? Math.Max(0.0, VerticalOffset) : 0.0; + var pen = new Pen(PatternBrush, style == PaperStyle.StrongRuled ? 2.0 : 1.0); + + if (style == PaperStyle.SoftRuled) + drawingContext.PushOpacity(0.55); + + DrawRuledLines(drawingContext, pen, spacing, verticalOffset); + + if (style == PaperStyle.SoftRuled) + drawingContext.Pop(); + } + + private void DrawRuledLines(DrawingContext drawingContext, Pen pen, double spacing, double verticalOffset) + { + var firstLineY = spacing - (verticalOffset % spacing) - 0.5; + if (firstLineY < 0) + firstLineY += spacing; + + for (var y = firstLineY; y < ActualHeight; y += spacing) + drawingContext.DrawLine(pen, new Point(0, y), new Point(ActualWidth, y)); + } +} diff --git a/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.Canvas.cs b/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.Canvas.cs index 63ece81..21b4437 100644 --- a/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.Canvas.cs +++ b/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.Canvas.cs @@ -12,6 +12,21 @@ public partial class WorkflowBuilderWindow private double _dragStartX; private double _dragStartY; + private void AddNodeMenuButton_Click(object sender, RoutedEventArgs e) + { + if (sender is Button button) + OpenButtonContextMenu(button); + } + + private static void OpenButtonContextMenu(Button button) + { + if (button.ContextMenu is not { } menu) + return; + + menu.PlacementTarget = button; + menu.IsOpen = true; + } + private void AddNodeOfKind_Click(object sender, RoutedEventArgs e) { if (sender is not FrameworkElement { Tag: string kindName } || @@ -21,10 +36,14 @@ private void AddNodeOfKind_Click(object sender, RoutedEventArgs e) } ViewModel.AddNode(kind); + ShowNodeInspector(); } private void DuplicateNode_Click(object sender, RoutedEventArgs e) - => ViewModel.DuplicateSelectedNode(); + { + if (ViewModel.DuplicateSelectedNode()) + ShowNodeInspector(); + } private void RemoveNode_Click(object sender, RoutedEventArgs e) => ViewModel.RemoveSelectedNode(); @@ -33,7 +52,10 @@ private void AutoLayout_Click(object sender, RoutedEventArgs e) => ViewModel.AutoLayoutSelectedWorkflow(); private void AddLink_Click(object sender, RoutedEventArgs e) - => ViewModel.AddLink(); + { + if (ViewModel.AddLink()) + InspectorTabs.SelectedItem = ConnectionsInspectorTab; + } private void RemoveLink_Click(object sender, RoutedEventArgs e) => ViewModel.RemoveSelectedLink(); @@ -65,6 +87,7 @@ private void WorkflowNode_MouseLeftButtonDown(object sender, MouseButtonEventArg return; ViewModel.SelectedNode = node; + ShowNodeInspector(); _draggedNode = node; _dragStartPoint = e.GetPosition(WorkflowDiagramSurface); _dragStartX = node.X; @@ -105,6 +128,10 @@ private void WorkflowNodeColor_Click(object sender, RoutedEventArgs e) node.Color = color; ViewModel.SelectedNode = node; + ShowNodeInspector(); e.Handled = true; } + + private void ShowNodeInspector() + => InspectorTabs.SelectedItem = NodeInspectorTab; } diff --git a/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.FileActions.cs b/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.FileActions.cs index cfc5038..fefaaf3 100644 --- a/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.FileActions.cs +++ b/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.FileActions.cs @@ -60,7 +60,7 @@ private void ImportWorkflowJson_Click(object sender, RoutedEventArgs e) { var dialog = new OpenFileDialog { - Filter = "Workflow JSON (*.workflow.json;*.json)|*.workflow.json;*.json|All files (*.*)|*.*", + Filter = "Reloadable workflow JSON (*.workflow.json;*.json)|*.workflow.json;*.json|All files (*.*)|*.*", FilterIndex = 1 }; @@ -112,8 +112,8 @@ private void ExportWorkflowJson_Click(object sender, RoutedEventArgs e) { ExportWorkflow( ".workflow.json", - "Workflow JSON (*.workflow.json)|*.workflow.json|JSON (*.json)|*.json|All files (*.*)|*.*", - "workflow JSON", + "Reloadable workflow JSON (*.workflow.json)|*.workflow.json|JSON (*.json)|*.json|All files (*.*)|*.*", + "reloadable workflow JSON", ViewModel.ExportSelectedWorkflowToFile); } @@ -121,20 +121,11 @@ private void ExportWorkflowText_Click(object sender, RoutedEventArgs e) { ExportWorkflow( ".workflow.txt", - "Workflow text (*.workflow.txt)|*.workflow.txt|Text (*.txt)|*.txt|All files (*.*)|*.*", - "workflow text", + "Readable workflow text (*.workflow.txt)|*.workflow.txt|Text (*.txt)|*.txt|All files (*.*)|*.*", + "readable text copy", ViewModel.ExportSelectedWorkflowTextToFile); } - private void ExportWorkflowMarkdown_Click(object sender, RoutedEventArgs e) - { - ExportWorkflow( - ".workflow.md", - "Workflow markdown (*.workflow.md)|*.workflow.md|Markdown (*.md)|*.md|All files (*.*)|*.*", - "workflow markdown", - ViewModel.ExportSelectedWorkflowMarkdownToFile); - } - private void ExportWorkflow( string defaultExtension, string filter, diff --git a/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.xaml b/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.xaml index a7d9439..fd82f2d 100644 --- a/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.xaml +++ b/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.xaml @@ -163,8 +163,8 @@ - @@ -194,20 +193,10 @@ - -