diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..b378090 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(ls -la \"/d/github/LanMountainDesktop/.claude/worktrees/agent-a4c5412322421ab67\" && ls -la \"/d/github/LanMountainDesktop\" && ls -la \"/d/github\")", + "Read(//d/github/**)", + "Bash(dotnet build *)", + "Bash(dotnet test *)", + "Bash(python -)", + "Bash(py -3 -c \"from pathlib import Path; p=Path\\(r'd:/github/LanMountainDesktop/LanMountainDesktop/ViewModels/SettingsViewModels.cs'\\); t=p.read_text\\(encoding='utf-8'\\); s=t.find\\('public sealed partial class UpdateSettingsPageViewModel : ViewModelBase'\\); e=t.find\\('public sealed partial class StudySettingsPageViewModel : ViewModelBase', s\\); assert s!=-1 and e!=-1; p.write_text\\(t[:s]+t[e:], encoding='utf-8'\\); print\\('ok'\\)\")", + "Bash(perl -0777 -i -pe \"s/public sealed partial class UpdateSettingsPageViewModel : ViewModelBase\\\\R\\\\{.*?\\\\R\\\\}\\\\R\\\\Rpublic sealed partial class StudySettingsPageViewModel : ViewModelBase/public sealed partial class StudySettingsPageViewModel : ViewModelBase/s\" \"d:/github/LanMountainDesktop/LanMountainDesktop/ViewModels/SettingsViewModels.cs\")" + ] + } +} diff --git a/.comate/specs/standby-digital-clock/doc.md b/.comate/specs/standby-digital-clock/doc.md new file mode 100644 index 0000000..d2bc6fe --- /dev/null +++ b/.comate/specs/standby-digital-clock/doc.md @@ -0,0 +1,291 @@ +# StandBy Digital Clock - iPhone 待机风格大数字时钟组件 + +## 1. 需求场景与处理逻辑 + +### 1.1 需求描述 +新增一个 4×2 尺寸的数字时钟桌面组件,视觉风格参考 iPhone 横屏充电时的 StandBy 待机显示——大面积、粗体、圆润的数字显示当前时间(HH:MM),数字采用不规则的自由排版(有微妙的垂直偏移,不在一条直线上),颜色使用 Monet 主题色而非纯黑/白,伴随数字切换时的流畅垂直滚动/翻转动画,下方显示日期信息。 + +### 1.2 用户体验目标 +- 大字号、圆润粗体的数字时间,远距离一目了然 +- 数字采用不规则自由排版(微妙垂直偏移),营造 iPhone StandBy 那种有机、散漫的视觉节奏 +- 数字使用 Monet 主题色(跟随壁纸/用户选色的强调色),而非死板的纯黑/白 +- 数字变化时执行垂直滑动动画(旧数字向上滑出,新数字从下方滑入),类似翻页时钟效果 +- 冒号(:)有呼吸闪烁效果 +- 支持夜间/日间模式自动切换 +- 点击组件可打开世界时钟 AirApp +- 支持时区配置(与现有桌面时钟共享设置体系) + +### 1.3 处理逻辑 +1. 组件加载时读取时区设置和秒针模式设置 +2. `DispatcherTimer` 每秒触发一次更新 +3. 当检测到分钟数变化时,触发分钟数字的垂直滑动动画 +4. 当检测到小时数变化时,触发小时数字的垂直滑动动画 +5. 冒号以 1 秒周期做透明度脉冲动画 +6. 每 tick 检查是否需要切换日间/夜间视觉模式 + +## 2. 架构与技术方案 + +### 2.1 组件架构 +遵循现有桌面组件架构模式: +- 继承 `UserControl`,实现 `IDesktopComponentWidget`, `ITimeZoneAwareComponentWidget`, `IComponentPlacementContextAware`, `IComponentRuntimeContextAware` +- AXAML 定义根布局结构,代码后置处理动画逻辑 +- 通过 `DesktopComponentDefinition` 注册到组件系统 + +### 2.2 数字滚动动画技术方案 +采用 Avalonia `RenderTransform` + `DoubleTransition` 实现数字滚动: + +**核心思路**:每个数位(共 4 位:H1, H2, M1, M2)使用 `ClipToBounds` 的容器,内含一个垂直排列的 `StackPanel`,包含当前数字和下一个数字。切换时通过 `TranslateTransform.Y` 的 `DoubleTransition` 实现平滑滚动。 + +``` +每位数字的结构: +┌─ DigitClip (ClipToBounds=true) ──────────┐ +│ ┌─ DigitStack (TranslateTransform.Y) ──┐ │ +│ │ [当前数字 TextBlock] │ │ +│ │ [新数字 TextBlock] │ │ +│ └───────────────────────────────────────┘ │ +└───────────────────────────────────────────┘ +``` + +当数字变化时: +1. 在 StackPanel 底部添加新数字的 TextBlock +2. 将 `TranslateTransform.Y` 从 0 动画过渡到 `-digitHeight` +3. 动画完成后移除旧数字,重置 Y 为 0 + +### 2.3 动画参数 +- 使用项目 `FluttermotionToken` 体系:滚动动画时长 `FluttermotionToken.Standard`(200ms) +- 缓动函数:`CubicEaseOut`(与项目现有动画风格一致) +- 冒号呼吸动画:透明度 1.0 → 0.3 → 1.0,周期 2 秒,使用 `DoubleTransition` + +### 2.4 尺寸与布局 +- 组件定义:`MinWidthCells = 4, MinHeightCells = 2` +- 缩放规则:2:1 比例(与 WorldClock 一致) +- 内部布局采用 `Viewbox` 包裹,确保在不同 cellSize 下自适应缩放 +- 数字字体大小:基准设计为 130px(在 Viewbox 内),实际显示由 Viewbox 缩放 + +### 2.5 布局风格——不规则自由排版(iPhone StandBy 风格) +iPhone StandBy 的数字不是规矩地排成一条直线,而是有微妙的垂直偏移和大小差异,营造出自由散漫、有机的视觉节奏: + +``` + H1 H2 : M1 M2 + ↗↘ ↘↗ ↗↘ ↘↗ + ↕+6 ↕+2 : ↕+4 ↕+2 + ↖ -3° ↗ +4° : ↖ -1° ↗ +5° + ←+6,↑-10 ←-2,↓+10 →+4,↑-3 ←-2,↓+12 +``` + +每个数字有三个自由度: +- **垂直偏移 (Y)**:H1=-10, H2=+10, 冒号=+8, M1=-3, M2=+12 +- **水平偏移 (X)**:H1=+6, H2=-2, 冒号=0, M1=+4, M2=-2 +- **旋转角度 (Z)**:H1=-4°, H2=+3°, 冒号=-1°, M1=-2°, M2=+5° + +### 2.6 视觉风格——圆润粗体 + Monet 主题色 +- **字体**:`FontWeight.Bold`,配合较大的字号,视觉上圆润饱满 +- **颜色**:使用项目 Monet 主题色系统,数字颜色跟随 `AdaptiveAccentBrush` / `SystemAccentColor`,而非纯黑/白 + - 数字颜色通过 `ComponentColorSchemeHelper.ShouldUseMonetColor()` 判断: + - 跟随系统:使用 `AdaptiveAccentBrush`(Monet 提取的强调色) + - 原生模式:使用组件自带的特色色彩 + - 夜间模式:深色渐变背景 + 主题色数字(亮色调) + - 日间模式:浅色渐变背景 + 主题色数字(深色调) + - 夜间暗光环境:数字过渡到柔和的红色调(`#FF6B4A`),模拟 iPhone StandBy 夜间红色调 +- **冒号颜色**:与数字同色,但有呼吸动画 +- **日期行**:使用 `AdaptiveTextMutedBrush`(跟随主题的弱化文字色),字号约 14-16px 基准 +- **根容器圆角**:`DesignCornerRadiusComponent`(遵循圆角规范) + +## 3. 受影响文件 + +### 3.1 新增文件 +| 文件 | 类型 | 说明 | +|------|------|------| +| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml` | 新增 | 组件 AXAML 布局 | +| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs` | 新增 | 组件代码后置(动画逻辑、时间更新、模式切换) | + +### 3.2 修改文件 +| 文件 | 修改类型 | 受影响函数/区域 | +|------|----------|-----------------| +| `LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs` | 新增常量 | 新增 `DesktopStandbyDigitalClock` 常量 | +| `LanMountainDesktop/ComponentSystem/ComponentRegistry.cs` | 新增定义 | `CreateDefault()` 中新增组件定义 | +| `LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs` | 新增运行时注册 | `GetDefaultRegistrations()` 中新增运行时注册项 | +| `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs` | 新增缩放规则 | `NormalizeAspectRatioForComponent()` 中为 StandbyDigitalClock 添加 2:1 缩放规则 | + +## 4. 实现细节 + +### 4.1 BuiltInComponentIds 新增常量 +```csharp +public const string DesktopStandbyDigitalClock = "DesktopStandbyDigitalClock"; +``` + +### 4.2 ComponentRegistry 新增定义 +```csharp +new DesktopComponentDefinition( + BuiltInComponentIds.DesktopStandbyDigitalClock, + "StandBy Clock", + "Clock", + "Clock", + MinWidthCells: 4, + MinHeightCells: 2, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true), +``` + +### 4.3 DesktopComponentRuntimeRegistry 新增注册 +```csharp +new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopStandbyDigitalClock, + "component.standby_digital_clock", + () => new StandbyDigitalClockWidget()), +``` + +### 4.4 NormalizeAspectRatioForComponent 缩放规则 +在 `case BuiltInComponentIds.DesktopWorldClock:` 的同一分支中添加 `BuiltInComponentIds.DesktopStandbyDigitalClock`,使用 2:1 比例规则。 + +### 4.5 AXAML 布局结构 +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### 4.6 数字滚动动画核心代码(伪代码) +```csharp +private void AnimateDigit(Border clip, Panel stack, TextBlock currentText, char newDigit, double digitHeight) +{ + var oldText = currentText; + var newTextBlock = new TextBlock + { + Text = newDigit.ToString(), + FontSize = oldText.FontSize, + FontWeight = oldText.FontWeight, + Foreground = oldText.Foreground, + Width = oldText.Width, + Height = digitHeight, + // 复制旧文本的所有样式属性 + }; + stack.Children.Add(newTextBlock); + + // 应用 TranslateTransform 过渡动画 + var transform = new TranslateTransform { Y = 0 }; + stack.RenderTransform = transform; + stack.Transitions = new Transitions + { + new DoubleTransition(TranslateTransform.YProperty, FluttermotionToken.Standard, new CubicEaseOut()) + }; + + // 触发动画:从当前位置滑到 -digitHeight + transform.Y = -digitHeight; + + // 动画完成后清理 + _ = DispatcherTimer.RunOnce(() => + { + stack.Children.Remove(oldText); + transform.Y = 0; + stack.Transitions = null; // 移除过渡,避免重置时再次动画 + // 更新引用 + UpdateCurrentTextReference(newTextBlock); + }, FluttermotionToken.Standard); +} +``` + +### 4.7 冒号呼吸动画 +使用 `DispatcherTimer` 每秒切换冒号透明度: +```csharp +private void ToggleColonOpacity() +{ + _colonVisible = !_colonVisible; + ColonText.Opacity = _colonVisible ? 1.0 : 0.3; +} +``` +配合 `DoubleTransition` 使透明度变化平滑过渡。 + +### 4.8 日间/夜间模式 +与 `AnalogClockWidget` 使用完全相同的判断逻辑: +- 检查 `ActualThemeVariant` +- 回退到 `AdaptiveSurfaceBaseBrush` 亮度计算 +- 夜间模式:深色渐变背景 + 浅色数字 +- 日间模式:浅色渐变背景 + 深色数字 + +### 4.9 时区与设置 +- 复用 `AnalogClockWidget` 的时区解析和设置加载逻辑 +- 使用 `ComponentSettingsSnapshot.DesktopClockTimeZoneId` 读取时区配置 +- 点击打开世界时钟 AirApp + +## 5. 边界条件与异常处理 + +| 场景 | 处理方式 | +|------|----------| +| 组件首次加载时数字尚未初始化 | 在构造函数中初始化所有数字为当前时间,不触发动画 | +| 快速连续触发数字变化(如时间同步导致跳变) | 在动画完成前忽略新的变化请求,或中断当前动画立即跳转到目标值 | +| cellSize 极小或极大 | `ApplyCellSize` 中 clamp 缩放因子(0.58-1.95,与 AnalogClockWidget 一致) | +| 时区切换 | 重新加载设置并更新所有数字(无动画,直接设置) | +| 主题切换 | 通过 `ApplyModeVisualIfNeeded()` 在下一个 tick 自动检测并切换 | +| 组件被销毁 | `DetachedFromVisualTree` 停止 timer,清理资源 | +| 冒号动画在组件不可见时 | timer 仍在运行但 Opacity 变化无性能开销;若需要可结合 `IDesktopPageVisibilityAwareComponentWidget` | + +## 6. 数据流路径 + +``` +DispatcherTimer (1s interval) + → OnTimerTick + → 计算当前时间 (TimeZoneInfo.ConvertTimeFromUtc) + → 比较新旧时间数字 + → 若有变化: AnimateDigit() 执行滚动动画 + → ToggleColonOpacity() 切换冒号 + → ApplyModeVisualIfNeeded() 检查日/夜间切换 + → UpdateDateText() 更新日期文本 + +用户点击 → OnPointerReleased → AirAppLauncherServiceProvider.OpenWorldClock() + +时区变更 → TimeZoneChanged event → RefreshFromSettings() → 无动画更新所有数字 +``` + +## 7. 预期成果 + +- 在桌面组件选择器中新增 "StandBy Clock" 组件,位于 Clock 分类 +- 拖放到桌面后显示 4×2 大数字时钟 +- 数字切换时有流畅的垂直滑动动画 +- 冒号有呼吸闪烁效果 +- 支持日间/夜间自动切换 +- 支持时区配置 +- 支持组件缩放(2:1 比例规则) diff --git a/.comate/specs/standby-digital-clock/summary.md b/.comate/specs/standby-digital-clock/summary.md new file mode 100644 index 0000000..a502339 --- /dev/null +++ b/.comate/specs/standby-digital-clock/summary.md @@ -0,0 +1,52 @@ +# StandBy Digital Clock 实现总结 + +## 完成状态 +全部任务已完成,构建通过(0 错误)。 + +## 变更清单 + +### 新增文件 +| 文件 | 说明 | +|------|------| +| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml` | AXAML 布局:不规则自由排版数字 + 冒号 + 日期,Monet 主题色绑定 | +| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs` | 代码后置:数字滚动动画、冒号呼吸、Monet 主题色、日/夜模式、时区支持 | + +### 修改文件 +| 文件 | 改动 | +|------|------| +| `LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs` | 新增 `DesktopStandbyDigitalClock` 常量 | +| `LanMountainDesktop/ComponentSystem/ComponentRegistry.cs` | 在 `CreateDefault()` 中新增 4×2 Clock 分类组件定义 | +| `LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs` | 新增 `StandbyDigitalClockWidget` 运行时注册 | +| `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs` | `NormalizeAspectRatioForComponent` 将 StandbyDigitalClock 加入 2:1 缩放规则 | + +## 核心设计要点 + +### 不规则自由排版(iPhone StandBy 风格) +- 每个数字有独立的垂直 Margin 偏移(H1 上移10, H2 下移8, M1 上移5, M2 下移10) +- 冒号比数字中心略低(下移6) +- 数字间距不等,营造自由散漫的视觉节奏 + +### Monet 主题色 +- 数字和冒号使用 `AdaptiveAccentBrush` / `SystemAccentColor`,跟随壁纸/用户选色的强调色 +- 通过 `ComponentColorSchemeHelper.ShouldUseMonetColor()` 判断: + - 跟随系统:使用 Monet 提取的强调色 + - 原生模式:使用暖橙红色(`#E84530` 日间 / `#FF8A65` 夜间),灵感来自 iPhone StandBy +- 日期文本使用 `AdaptiveTextMutedBrush` + +### 数字滚动动画 +- `TranslateTransform.Y` + `DoubleTransition`(200ms CubicEaseOut) +- 动画完成后清理旧 TextBlock 并重置 transform + +### 冒号呼吸 +- 每秒切换 Opacity(1.0 ↔ 0.25),配合 400ms CubicEaseInOut 平滑过渡 + +### 日/夜模式 +- 检测 `ActualThemeVariant` + `AdaptiveSurfaceBaseBrush` 亮度计算 +- 夜间:深色渐变背景 + 亮调强调色数字 +- 日间:浅色渐变背景 + 深调强调色数字 + +### 组件规格 +- 尺寸:4×2 (MinWidthCells=4, MinHeightCells=2) +- 分类:Clock +- 缩放:2:1 比例 (Proportional) +- 字体:FontWeight.Bold, 120px 基准 diff --git a/.comate/specs/standby-digital-clock/tasks.md b/.comate/specs/standby-digital-clock/tasks.md new file mode 100644 index 0000000..6cb7dab --- /dev/null +++ b/.comate/specs/standby-digital-clock/tasks.md @@ -0,0 +1,25 @@ +# StandBy Digital Clock 任务计划 + +- [x] Task 1: 注册组件定义与运行时 + - 1.1: 在 `BuiltInComponentIds.cs` 中新增 `DesktopStandbyDigitalClock` 常量 + - 1.2: 在 `ComponentRegistry.cs` 的 `CreateDefault()` 中新增 `DesktopComponentDefinition`(4×2, Clock 分类, Proportional) + - 1.3: 在 `DesktopComponentRuntimeRegistry.cs` 的 `GetDefaultRegistrations()` 中新增运行时注册项 + - 1.4: 在 `MainWindow.ComponentSystem.cs` 的 `NormalizeAspectRatioForComponent()` 中为 StandbyDigitalClock 添加 2:1 缩放规则 + +- [x] Task 2: 创建 StandbyDigitalClockWidget AXAML 布局 + - 2.1: 创建 `StandbyDigitalClockWidget.axaml`,定义 RootBorder(DesignCornerRadiusComponent)、Viewbox、时间数字区域(4 个 ClipToBounds 数位容器 + 冒号)、日期文本 + - 2.2: 确保 Viewbox 内基准设计尺寸为 400×200,数字使用 FontWeight.Bold,冒号和日期布局合理 + +- [x] Task 3: 实现组件代码后置(核心逻辑与动画) + - 3.1: 创建 `StandbyDigitalClockWidget.axaml.cs`,实现 `IDesktopComponentWidget`, `ITimeZoneAwareComponentWidget`, `IComponentPlacementContextAware`, `IComponentRuntimeContextAware` 接口 + - 3.2: 实现 DispatcherTimer 每秒更新逻辑,比较新旧时间数字,触发数位滚动动画 + - 3.3: 实现数字垂直滚动动画:每位数字使用 TranslateTransform.Y + DoubleTransition,旧数字上滑出新数字滑入,动画完成后清理 + - 3.4: 实现冒号呼吸动画:每秒切换透明度,配合 DoubleTransition 平滑过渡 + - 3.5: 实现日间/夜间模式切换:检测 ActualThemeVariant 和亮度,切换背景渐变和数字颜色;夜间暗光环境过渡到红色调 + - 3.6: 实现 ApplyCellSize 缩放逻辑,clamp 缩放因子,更新圆角和间距 + - 3.7: 实现时区设置加载(复用 AnalogClockWidget 逻辑),点击打开世界时钟 AirApp + - 3.8: 实现日期文本更新逻辑,显示完整日期和星期 + +- [x] Task 4: 构建验证与调试 + - 4.1: 执行 `dotnet build` 确保编译通过,修复所有错误 + - 4.2: 检查圆角规范合规性(根容器使用 DesignCornerRadiusComponent) diff --git a/.cursor/skills/.gitkeep b/.cursor/skills/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.cursor/skills/.gitkeep @@ -0,0 +1 @@ + diff --git a/.github/workflows/ddss-publish.yml b/.github/workflows/ddss-publish.yml index 718407a..ee76ab6 100644 --- a/.github/workflows/ddss-publish.yml +++ b/.github/workflows/ddss-publish.yml @@ -1,5 +1,9 @@ name: DDSS +concurrency: + group: ddss-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }} + cancel-in-progress: false + on: workflow_run: workflows: @@ -31,7 +35,7 @@ jobs: fetch-depth: 0 submodules: recursive - - name: Resolve release tag + - name: Resolve release tag and channel env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash @@ -50,7 +54,21 @@ jobs: fi echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV" - echo "S3_BASE_URL=${{ vars.S3_ENDPOINT }}/${{ vars.S3_BUCKET }}/lanmountain/update/releases/${TAG}/assets" >> "$GITHUB_ENV" + IS_PRERELEASE="$(gh release view "$TAG" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease')" + if [[ "$IS_PRERELEASE" == "true" ]]; then + CHANNEL="preview" + else + CHANNEL="stable" + fi + echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV" + echo "DDSS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/ddss-latest.json" >> "$GITHUB_ENV" + PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}" + if [[ -z "$PUBLIC_BASE" ]]; then + PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update" + fi + PUBLIC_BASE="${PUBLIC_BASE%/}" + echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV" + echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$GITHUB_ENV" - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -89,6 +107,25 @@ jobs: gh release download "$RELEASE_TAG" -D release-assets find release-assets -maxdepth 1 -type f | sort + - name: Prepare PLONDS static output + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + rm -rf plonds-static + mkdir -p plonds-static + if [[ "${{ github.event_name }}" == "workflow_run" ]]; then + gh run download "${{ github.event.workflow_run.id }}" -n plonds-static -D plonds-static || true + fi + if [[ ! -d plonds-static/repo/sha256 && -f release-assets/plonds-static.zip ]]; then + unzip -q release-assets/plonds-static.zip -d plonds-static + fi + if [[ ! -d plonds-static/repo/sha256 || ! -d plonds-static/meta/channels || ! -d plonds-static/manifests ]]; then + echo "PLONDS static output is missing. Run the PLONDS workflow for this release first." + exit 1 + fi + - name: Upload release assets to Rainyun S3 env: AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }} @@ -121,6 +158,59 @@ jobs: --metadata "sha256=$sha256" done + - name: Upload PLONDS static output to Rainyun S3 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }} + AWS_DEFAULT_REGION: ${{ vars.S3_REGION }} + AWS_REGION: ${{ vars.S3_REGION }} + S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + shell: bash + run: | + set -euo pipefail + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3 sync \ + plonds-static/ \ + "s3://$S3_BUCKET/lanmountain/update/" \ + --only-show-errors + + - name: Mirror installers to Rainyun S3 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }} + AWS_DEFAULT_REGION: ${{ vars.S3_REGION }} + AWS_REGION: ${{ vars.S3_REGION }} + S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + shell: bash + run: | + set -euo pipefail + version="${RELEASE_TAG#v}" + for file in release-assets/*; do + [[ -f "$file" ]] || continue + name="$(basename "$file")" + platform="" + case "$name" in + *.exe) + if [[ "$name" == *x86* ]]; then platform="windows-x86"; else platform="windows-x64"; fi + ;; + *.deb) + platform="linux-x64" + ;; + *.dmg) + if [[ "$name" == *arm64* ]]; then platform="macos-arm64"; else platform="macos-x64"; fi + ;; + esac + [[ -n "$platform" ]] || continue + key="lanmountain/update/installers/${platform}/${version}/${name}" + sha256="$(sha256sum "$file" | awk '{print $1}')" + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \ + --bucket "$S3_BUCKET" \ + --key "$key" \ + --body "$file" \ + --metadata "sha256=$sha256" + done + - name: Build DDSS manifest shell: bash run: | @@ -135,6 +225,33 @@ jobs: --repository "${{ github.repository }}" \ --s3-base-url "$S3_BASE_URL" + - name: Validate DDSS asset references in Rainyun S3 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }} + AWS_DEFAULT_REGION: ${{ vars.S3_REGION }} + AWS_REGION: ${{ vars.S3_REGION }} + S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + shell: bash + run: | + set -euo pipefail + keys=$(jq -r '.assets[]?.mirrors[]?.url // empty' ddss-output/ddss.json \ + | sed -n 's#^.*/lanmountain/update/\(.*\)$#lanmountain/update/\1#p' \ + | sort -u) + + if [[ -z "$keys" ]]; then + echo "No S3-backed asset URLs found in ddss.json" + exit 1 + fi + + while IFS= read -r key; do + [[ -n "$key" ]] || continue + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \ + --bucket "$S3_BUCKET" \ + --key "$key" >/dev/null + done <<< "$keys" + - name: Upload DDSS manifest to release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -143,7 +260,7 @@ jobs: set -euo pipefail gh release upload "$RELEASE_TAG" ddss-output/ddss.json ddss-output/ddss.json.sig --clobber - - name: Upload DDSS manifest to Rainyun S3 + - name: Upload DDSS manifest to Rainyun S3 staging env: AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }} @@ -164,3 +281,101 @@ jobs: --body "$file" \ --metadata "sha256=$sha256" done + + - name: Prepare DDSS channel pointer + shell: bash + run: | + set -euo pipefail + pointer_file="ddss-output/ddss-latest.json" + cat > "$pointer_file" <<'JSON' + { + "schemaVersion": 1, + "channel": "__CHANNEL__", + "releaseTag": "__TAG__", + "version": "__VERSION__", + "updatedAt": "__UPDATED_AT__", + "manifest": { + "url": "__MANIFEST_URL__", + "signatureUrl": "__SIG_URL__" + } + } + JSON + + manifest_url="${S3_BASE_URL}/ddss.json" + sig_url="${S3_BASE_URL}/ddss.json.sig" + version="${RELEASE_TAG#v}" + updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + sed -i "s|__CHANNEL__|${RELEASE_CHANNEL}|g" "$pointer_file" + sed -i "s|__TAG__|${RELEASE_TAG}|g" "$pointer_file" + sed -i "s|__VERSION__|${version}|g" "$pointer_file" + sed -i "s|__UPDATED_AT__|${updated_at}|g" "$pointer_file" + sed -i "s|__MANIFEST_URL__|${manifest_url}|g" "$pointer_file" + sed -i "s|__SIG_URL__|${sig_url}|g" "$pointer_file" + + jq -e . "$pointer_file" >/dev/null + + - name: Atomically publish DDSS channel pointer + env: + AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }} + AWS_DEFAULT_REGION: ${{ vars.S3_REGION }} + AWS_REGION: ${{ vars.S3_REGION }} + S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + shell: bash + run: | + set -euo pipefail + pointer_file="ddss-output/ddss-latest.json" + staging_key="lanmountain/update/releases/${RELEASE_TAG}/assets/ddss-latest.json" + + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \ + --bucket "$S3_BUCKET" \ + --key "$staging_key" \ + --body "$pointer_file" + + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \ + --bucket "$S3_BUCKET" \ + --key "$DDSS_CHANNEL_POINTER_KEY" \ + --body "$pointer_file" + + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \ + --bucket "$S3_BUCKET" \ + --key "$DDSS_CHANNEL_POINTER_KEY" >/dev/null + + curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/ddss-latest.json" >/dev/null + + - name: Verify Rainyun S3 PLONDS output + env: + AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }} + AWS_DEFAULT_REGION: ${{ vars.S3_REGION }} + AWS_REGION: ${{ vars.S3_REGION }} + S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + shell: bash + run: | + set -euo pipefail + mapfile -t required < <( + { + find plonds-static/meta/channels -path '*/latest.json' -type f | sort | head -n 1 + find plonds-static/meta/distributions -name '*.json' -type f | sort | head -n 1 + find plonds-static/manifests -name 'plonds-filemap.json' -type f | sort | head -n 1 + find plonds-static/manifests -name 'plonds-filemap.json.sig' -type f | sort | head -n 1 + find plonds-static/repo/sha256 -type f | sort | head -n 1 + } | sed '/^$/d' + ) + + if [[ "${#required[@]}" -lt 5 ]]; then + echo "Not enough PLONDS static files to verify." + exit 1 + fi + + for path in "${required[@]}"; do + rel="${path#plonds-static/}" + key="lanmountain/update/${rel}" + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \ + --bucket "$S3_BUCKET" \ + --key "$key" >/dev/null + curl -fsSI "$S3_PUBLIC_BASE_URL/$rel" >/dev/null + done diff --git a/.github/workflows/ddss-rollback.yml b/.github/workflows/ddss-rollback.yml new file mode 100644 index 0000000..c8dacd7 --- /dev/null +++ b/.github/workflows/ddss-rollback.yml @@ -0,0 +1,146 @@ +name: DDSS Rollback + +on: + workflow_dispatch: + inputs: + channel: + description: 'Target channel to rollback' + required: true + type: choice + default: stable + options: + - stable + - preview + target_tag: + description: 'Release tag to rollback to (e.g. v1.2.3)' + required: true + type: string + +env: + DOTNET_VERSION: '10.0.x' + +jobs: + rollback: + runs-on: ubuntu-latest + permissions: + contents: read + + concurrency: + group: ddss-rollback-${{ github.event.inputs.channel }} + cancel-in-progress: false + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve rollback context + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + + RAW_TAG="${{ github.event.inputs.target_tag }}" + if [[ "$RAW_TAG" == v* ]]; then + TAG="$RAW_TAG" + else + TAG="v$RAW_TAG" + fi + + CHANNEL="${{ github.event.inputs.channel }}" + + gh release view "$TAG" --repo "${{ github.repository }}" --json tagName >/dev/null + + PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}" + if [[ -z "$PUBLIC_BASE" ]]; then + PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update" + fi + PUBLIC_BASE="${PUBLIC_BASE%/}" + + echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV" + echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV" + echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV" + echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$GITHUB_ENV" + echo "DDSS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/ddss-latest.json" >> "$GITHUB_ENV" + + - name: Validate rollback target assets + env: + AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }} + AWS_DEFAULT_REGION: ${{ vars.S3_REGION }} + AWS_REGION: ${{ vars.S3_REGION }} + S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + shell: bash + run: | + set -euo pipefail + + for name in ddss.json ddss.json.sig; do + key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}" + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \ + --bucket "$S3_BUCKET" \ + --key "$key" >/dev/null + done + + - name: Build rollback pointer + shell: bash + run: | + set -euo pipefail + + mkdir -p rollback-output + pointer_file="rollback-output/ddss-latest.json" + + manifest_url="${S3_BASE_URL}/ddss.json" + sig_url="${S3_BASE_URL}/ddss.json.sig" + version="${RELEASE_TAG#v}" + updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + cat > "$pointer_file" </dev/null + + - name: Publish rollback pointer + env: + AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }} + AWS_DEFAULT_REGION: ${{ vars.S3_REGION }} + AWS_REGION: ${{ vars.S3_REGION }} + S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + shell: bash + run: | + set -euo pipefail + + pointer_file="rollback-output/ddss-latest.json" + + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \ + --bucket "$S3_BUCKET" \ + --key "$DDSS_CHANNEL_POINTER_KEY" \ + --body "$pointer_file" + + aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \ + --bucket "$S3_BUCKET" \ + --key "$DDSS_CHANNEL_POINTER_KEY" >/dev/null + + curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/ddss-latest.json" >/dev/null + + - name: Print rollback summary + shell: bash + run: | + set -euo pipefail + echo "Rolled back channel '${RELEASE_CHANNEL}' to '${RELEASE_TAG}'." + echo "Pointer: ${S3_PUBLIC_BASE_URL}/meta/channels/${RELEASE_CHANNEL}/ddss-latest.json" diff --git a/.github/workflows/plonds-build.yml b/.github/workflows/plonds-build.yml index 3f53ade..8eb99ff 100644 --- a/.github/workflows/plonds-build.yml +++ b/.github/workflows/plonds-build.yml @@ -1,10 +1,15 @@ name: PLONDS +concurrency: + group: plonds-${{ github.event_name }}-${{ github.event.release.tag_name || github.event.inputs.tag || github.run_id }} + cancel-in-progress: false + on: release: types: - published - prereleased + - edited workflow_dispatch: inputs: tag: @@ -66,6 +71,11 @@ jobs: echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV" echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV" echo "BASELINE_TAG_INPUT=${BASELINE_TAG}" >> "$GITHUB_ENV" + PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}" + if [[ -z "$PUBLIC_BASE" ]]; then + PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update" + fi + echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE%/}" >> "$GITHUB_ENV" - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -189,7 +199,9 @@ jobs: '--current-zip', $currentZip, '--output-dir', 'plonds-output', '--private-key', $env:UPDATE_PRIVATE_KEY_PATH, - '--channel', $plan.channel + '--channel', $plan.channel, + '--static-output-dir', 'plonds-output/static', + '--update-base-url', $env:S3_PUBLIC_BASE_URL ) if ([bool]$entry.isFullPayload) { @@ -212,6 +224,29 @@ jobs: --output-dir plonds-output ` --private-key $env:UPDATE_PRIVATE_KEY_PATH + foreach ($entry in $plan.platforms) { + $summary = Get-Content "plonds-output/platform-summaries/platform-summary-$($entry.platform).json" | ConvertFrom-Json + $required = @( + "plonds-output/static/meta/channels/$($plan.channel)/$($entry.platform)/latest.json", + "plonds-output/static/meta/distributions/$($summary.distributionId).json", + "plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json", + "plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json.sig" + ) + + foreach ($path in $required) { + if (-not (Test-Path $path)) { + throw "Missing PLONDS static output: $path" + } + } + } + + $objects = Get-ChildItem -Path "plonds-output/static/repo/sha256" -File -Recurse -ErrorAction SilentlyContinue + if (-not $objects -or $objects.Count -eq 0) { + throw "PLONDS static object repository is empty." + } + + Compress-Archive -Path "plonds-output/static/*" -DestinationPath "plonds-output/release-assets/plonds-static.zip" -Force + - name: Upload PLONDS assets to release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -233,3 +268,11 @@ jobs: path: plonds-run-metadata/tag.txt if-no-files-found: error retention-days: 7 + + - name: Upload PLONDS static artifact + uses: actions/upload-artifact@v4 + with: + name: plonds-static + path: plonds-output/static/** + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ceeb801..dd72a74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -179,6 +179,7 @@ jobs: -p:PublishSingleFile=false ` -p:DebugType=none ` -p:DebugSymbols=false ` + -p:SkipAirAppHostBuild=true ` -p:PublishTrimmed=false ` -p:PublishReadyToRun=false ` -p:Version=${{ needs.prepare.outputs.version }} ` @@ -193,6 +194,7 @@ jobs: -p:PublishSingleFile=false ` -p:DebugType=none ` -p:DebugSymbols=false ` + -p:SkipAirAppHostBuild=true ` -p:PublishTrimmed=false ` -p:PublishReadyToRun=false ` -p:Version=${{ needs.prepare.outputs.version }} ` @@ -202,6 +204,48 @@ jobs: } shell: pwsh + - name: Publish AirAppHost + run: | + $arch = "${{ matrix.arch }}" + $selfContained = "${{ matrix.self_contained }}" -eq "true" + $publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" } + + if ($selfContained) { + dotnet publish LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj ` + -c Release ` + -o ./$publishDir ` + --self-contained:false ` + -r win-$arch ` + -p:PublishSingleFile=false ` + -p:DebugType=none ` + -p:DebugSymbols=false ` + -p:PublishTrimmed=false ` + -p:PublishReadyToRun=false ` + -p:BuildingAirAppHost=true ` + -p:SkipAirAppHostBuild=true ` + -p:Version=${{ needs.prepare.outputs.version }} ` + -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} ` + -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} ` + -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} + } else { + dotnet publish LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj ` + -c Release ` + -o ./$publishDir ` + --self-contained:false ` + -p:PublishSingleFile=false ` + -p:DebugType=none ` + -p:DebugSymbols=false ` + -p:PublishTrimmed=false ` + -p:PublishReadyToRun=false ` + -p:BuildingAirAppHost=true ` + -p:SkipAirAppHostBuild=true ` + -p:Version=${{ needs.prepare.outputs.version }} ` + -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} ` + -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} ` + -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} + } + shell: pwsh + - name: Restructure for Launcher run: | $version = "${{ needs.prepare.outputs.version }}" @@ -227,6 +271,18 @@ jobs: Move-Item -Path $newStructure -Destination $publishDir -Force shell: pwsh + - name: Optimize and Guard Windows Payload + run: | + $arch = "${{ matrix.arch }}" + $selfContained = "${{ matrix.self_contained }}" -eq "true" + $publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" } + + ./LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 ` + -PublishDir $publishDir ` + -RuntimeIdentifier "win-$arch" ` + -AssertClean + shell: pwsh + - name: Install Inno Setup and 7z run: | choco install innosetup -y --no-progress @@ -418,6 +474,7 @@ jobs: -p:SelfContained=true \ -p:DebugType=none \ -p:DebugSymbols=false \ + -p:SkipAirAppHostBuild=true \ -p:PublishTrimmed=false \ -p:PublishReadyToRun=false \ -p:Version=${{ needs.prepare.outputs.version }} \ @@ -606,6 +663,7 @@ jobs: -p:SelfContained=true \ -p:DebugType=none \ -p:DebugSymbols=false \ + -p:SkipAirAppHostBuild=true \ -p:PublishTrimmed=false \ -p:PublishReadyToRun=false \ -p:Version=${{ needs.prepare.outputs.version }} \ diff --git a/.gitignore b/.gitignore index a2037bb..8f095ce 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ # dotenv files .env +# Local NuGet global packages (NuGet.Config globalPackagesFolder) +.nuget/packages/ + # User-specific files *.rsuser *.suo @@ -515,3 +518,4 @@ nul /velopack-output-local-verify /velopack-output-local /test-aot-publish +/.claude/worktrees diff --git a/.trae/documents/class-schedule-widget-redesign.md b/.trae/documents/class-schedule-widget-redesign.md new file mode 100644 index 0000000..f687fae --- /dev/null +++ b/.trae/documents/class-schedule-widget-redesign.md @@ -0,0 +1,403 @@ +# 课程表组件视觉重构 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 彻底重构阑山桌面的课程表(ClassScheduleWidget)组件视觉设计,参考小爱课程表的桌面小部件风格,实现时间轴+色块卡片布局、科目自动配色、当前课程进度高亮等现代化视觉效果。 + +**Architecture:** 保留现有数据层(ClassIslandScheduleDataService、Models)和组件注册机制不变,仅重构 Widget 的 UI 渲染层(XAML + code-behind 中的渲染逻辑)。新增科目配色服务,为每门课程分配稳定的区分色。先创建 HTML Mock 验证视觉效果,再移植到 Avalonia XAML。 + +**Tech Stack:** Avalonia UI (XAML + C# code-behind)、HTML/CSS (Mock 预览) + +--- + +## 当前状态分析 + +### 现有组件结构 +- **XAML**: `ClassScheduleWidget.axaml` — 仅定义了 RootBorder、HeaderGrid(日期+星期+课数)、ScrollViewer+CourseListPanel、StatusTextBlock +- **Code-behind**: `ClassScheduleWidget.axaml.cs` — 所有课程项 UI 在 `CreateSingleItemControl()` 中手动构建:圆点(Bullet) + 文字栈(课程名/时间/详情) +- **数据层**: `ClassIslandScheduleDataService` + `ClassIslandScheduleModels` — 不变 +- **编辑器**: `ClassScheduleComponentEditor.axaml(.cs)` — 不变 + +### 现有设计问题 +1. **视觉单调**: 仅用小圆点区分课程,所有课程外观一致,缺乏层次感 +2. **信息密度低**: 课程名、时间、教师名挤在一行,可读性差 +3. **当前课不突出**: 仅通过圆点颜色变化标识当前课程,几乎无法一眼识别 +4. **色彩硬编码**: 颜色值直接写在 C# 中,不使用语义资源键,不遵循 VISUAL_SPEC +5. **无时间轴感**: 列表式排列无法体现课程的时间先后和持续长度 + +### 小爱课程表参考设计特征 +1. **时间轴布局**: 左侧显示时间刻度,右侧是课程色块卡片 +2. **科目配色**: 每门课程自动分配一种柔和区分色,卡片使用对应色块背景 +3. **当前课高亮**: 正在进行的课程有明显的视觉强调(放大/进度条/发光) +4. **进度指示**: 当前课程显示上课进度(已过时间/总时长) +5. **紧凑信息**: 课程名+教室/教师信息在色块内清晰排列 +6. **课间分隔**: 课间休息区域有视觉分隔(虚线/淡色区域) + +--- + +## 设计方案 + +### 视觉论文 (Visual Thesis) +时间轴驱动的色块卡片布局,柔和科目配色,当前课程进度高亮——在桌面小组件有限空间内实现信息密度与美感的平衡。 + +### 布局结构 +``` +┌─────────────────────────────────────┐ +│ 7/24 周一 今天3节课 │ ← 头部:日期 + 星期 + 课数 +├─────────────────────────────────────┤ +│ 08:00 ┌──────────────────────┐ │ +│ │ 语文 │ │ ← 科目色块卡片 +│ │ 王老师 · 教室301 │ │ +│ 08:45 └──────────────────────┘ │ +│ ┌──────────────────────┐ │ +│ │ 数学 ████████░░ 75% │ │ ← 当前课:进度条 + 高亮 +│ │ 李老师 · 教室205 │ │ +│ 09:30 └──────────────────────┘ │ +│ ... │ +└─────────────────────────────────────┘ +``` + +### 科目配色方案 +使用一组预定义的柔和色彩,按科目名哈希值稳定分配: +- 语文: #5B8FF9 (蓝) +- 数学: #F6903D (橙) +- 英语: #5AD8A6 (绿) +- 物理: #E8684A (红) +- 化学: #9270CA (紫) +- 生物: #FF9845 (琥珀) +- 历史: #1E9493 (青) +- 地理: #FF99C3 (粉) +- 政治: #7262FD (靛) +- 体育: #78D3F8 (天蓝) +- 默认: #8B95A5 (灰) + +### 当前课程高亮 +- 卡片左侧显示 3px 宽的强调色竖条 +- 卡片底部显示细进度条(已过时间/总时长) +- 卡片背景使用科目色的 15% 透明度版本 +- 非当前课程使用科目色的 8% 透明度版本 + +--- + +## 文件变更清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml` | 修改 | 重构 XAML 布局:时间轴+卡片区域 | +| `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs` | 修改 | 重构渲染逻辑:色块卡片、科目配色、进度条 | +| `LanMountainDesktop/Views/Components/SubjectColorService.cs` | 新建 | 科目配色服务:稳定哈希分配颜色 | +| `mocks/class-schedule-mock.html` | 新建 | HTML Mock 预览(亮色+暗色) | + +--- + +## Task 分解 + +### Task 1: 创建 HTML Mock 预览 + +**Files:** +- Create: `mocks/class-schedule-mock.html` + +- [ ] **Step 1: 创建 HTML Mock 文件** + +创建完整的 HTML Mock,包含: +- 亮色/暗色主题切换 +- 时间轴+色块卡片布局 +- 科目自动配色 +- 当前课程进度条高亮 +- 课间分隔区域 +- 响应式尺寸(模拟桌面组件 2x4 / 4x4 等尺寸) + +Mock 中应包含示例数据: +``` +08:00-08:45 语文 王老师 +08:55-09:40 数学 李老师 (当前课,进度 60%) +09:50-10:35 英语 张老师 +10:45-11:30 物理 赵老师 +14:00-14:45 化学 陈老师 +14:55-15:40 生物 刘老师 +``` + +- [ ] **Step 2: 在浏览器中打开 Mock 验证效果** + +Run: `start mocks/class-schedule-mock.html` + +- [ ] **Step 3: 根据视觉效果调整 Mock 细节** + +调整间距、色值、字体大小、进度条样式等直到满意。 + +--- + +### Task 2: 创建科目配色服务 + +**Files:** +- Create: `LanMountainDesktop/Views/Components/SubjectColorService.cs` + +- [ ] **Step 1: 实现 SubjectColorService** + +```csharp +using System; +using Avalonia.Media; + +namespace LanMountainDesktop.Views.Components; + +internal static class SubjectColorService +{ + private static readonly (string Name, string Hex)[] Palette = [ + ("语文", "#5B8FF9"), + ("数学", "#F6903D"), + ("英语", "#5AD8A6"), + ("物理", "#E8684A"), + ("化学", "#9270CA"), + ("生物", "#FF9845"), + ("历史", "#1E9493"), + ("地理", "#FF99C3"), + ("政治", "#7262FD"), + ("体育", "#78D3F8"), + ("音乐", "#F25E7E"), + ("美术", "#C2A1FD"), + ]; + + private static readonly string DefaultHex = "#8B95A5"; + + public static Color ResolveColor(string subjectName) + { + foreach (var (name, hex) in Palette) + { + if (subjectName.Contains(name, StringComparison.OrdinalIgnoreCase)) + { + return Color.Parse(hex); + } + } + + var hash = StableHash(subjectName); + var index = (int)(hash % (uint)Palette.Length); + return Color.Parse(Palette[index].Hex); + } + + public static Color ResolveBackgroundColor(string subjectName, bool isCurrent, bool isNight) + { + var baseColor = ResolveColor(subjectName); + var alpha = isCurrent ? 0.18 : 0.08; + return new Color( + (byte)(alpha * 255), + baseColor.R, + baseColor.G, + baseColor.B); + } + + public static Color ResolveForegroundColor(string subjectName, bool isNight) + { + var baseColor = ResolveColor(subjectName); + return isNight + ? new Color(0xFF, (byte)Math.Min(255, baseColor.R + 60), (byte)Math.Min(255, baseColor.G + 60), (byte)Math.Min(255, baseColor.B + 60)) + : baseColor; + } + + private static uint StableHash(string input) + { + uint hash = 5381; + foreach (var c in input) + { + hash = ((hash << 5) + hash) ^ (uint)c; + } + return hash; + } +} +``` + +- [ ] **Step 2: 验证编译通过** + +Run: `dotnet build LanMountainDesktop/LanMountainDesktop.csproj -c Debug --no-restore` + +--- + +### Task 3: 重构 ClassScheduleWidget XAML 布局 + +**Files:** +- Modify: `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml` + +- [ ] **Step 1: 重写 XAML 布局** + +新的 XAML 结构: +- RootBorder 保持 `DesignCornerRadiusComponent` +- 头部区域:日期(大号)+ 星期 + 课数 + 进度摘要 +- 课程列表区域:ScrollViewer 包裹 StackPanel +- 每个 CourseItem 将在 code-behind 中构建为:Grid(时间列 + 卡片列) + - 时间列:StartTime / EndTime 垂直排列 + - 卡片列:Border(科目色背景) > StackPanel(课程名 + 教师信息 + 进度条) + +XAML 只定义骨架,课程项仍由 code-behind 动态构建(因为需要科目配色和进度计算)。 + +```xml + + + + + + + + + + + + + + + + + + + + + +``` + +--- + +### Task 4: 重构 ClassScheduleWidget 渲染逻辑 + +**Files:** +- Modify: `LanMountainDesktop/Views/Components/ClassScheduleWidget.axaml.cs` + +- [ ] **Step 1: 扩展 CourseItemViewModel** + +在现有 record 中增加字段: + +```csharp +private sealed record CourseItemViewModel( + string Name, + string TimeRange, + string Detail, + bool IsCurrent, + TimeSpan StartTime, + TimeSpan EndTime, + double Progress); +``` + +- [ ] **Step 2: 修改 BuildCourseItemViewModels 计算进度** + +在构建 ViewModel 时,对当前课程计算 Progress = (now - startTime) / (endTime - startTime)。 + +- [ ] **Step 3: 重写 CreateSingleItemControl** + +新的课程项 UI 结构: + +``` +Grid (2列: 时间列 Auto + 卡片列 *) +├── StackPanel (时间列) +│ ├── TextBlock (开始时间, 如 "08:00") +│ └── TextBlock (结束时间, 如 "08:45", 较淡) +└── Border (卡片列, 科目色背景, 圆角 DesignCornerRadiusSm) + ├── 左侧强调竖条 (当前课显示, 3px宽, 科目色) + └── StackPanel + ├── TextBlock (课程名, 科目色前景, 加粗) + ├── TextBlock (教师/教室, 次要色) + └── ProgressBar (当前课显示, 科目色) +``` + +关键改动点: +1. 移除圆点(Bullet),改用时间轴左侧时间标签 +2. 课程卡片使用 `SubjectColorService` 配色 +3. 当前课程卡片左侧显示强调竖条 + 底部进度条 +4. 课间区域用淡色分隔线标识 +5. 颜色使用语义资源键(`AdaptiveTextPrimaryBrush` 等),科目色通过 `SubjectColorService` 获取 + +- [ ] **Step 4: 重写 ApplyAdaptiveLayout** + +更新自适应布局逻辑: +- 头部日期/星期/课数徽章的字号和间距 +- 移除旧的圆点、文字栈相关计算 +- 新增时间列宽度、卡片圆角、进度条高度等计算 +- 使用 `ComponentChromeCornerRadiusHelper` 获取圆角 Token + +- [ ] **Step 5: 更新 IncrementalUpdateItems 和 IncrementalUpdateCurrentCourseHighlight** + +适配新的 UI 结构: +- 更新进度条值 +- 更新科目色背景 +- 更新强调竖条可见性 + +- [ ] **Step 6: 更新 RefreshSchedule 中的时间计算** + +在 `BuildCourseItemViewModels` 中传入 `StartTime`/`EndTime`/`Progress`。 + +- [ ] **Step 7: 验证编译通过** + +Run: `dotnet build LanMountainDesktop/LanMountainDesktop.csproj -c Debug` + +--- + +### Task 5: 验证与测试 + +- [ ] **Step 1: 运行项目查看效果** + +Run: `dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj` + +- [ ] **Step 2: 运行相关测试** + +Run: `dotnet test LanMountainDesktop.slnx -c Debug` + +- [ ] **Step 3: 检查圆角规范合规** + +确认 RootBorder 使用 `DesignCornerRadiusComponent`,内部卡片使用 `DesignCornerRadiusSm`/`DesignCornerRadiusMd`,无硬编码圆角值。 + +--- + +## 假设与决策 + +1. **科目配色**: 使用预定义调色板 + 哈希回退,不依赖 ClassIsland 数据中的科目颜色(因为 ClassIsland 不提供科目颜色字段) +2. **进度条**: 仅当前课程显示进度条,非当前课程不显示 +3. **课间分隔**: 用 4px 间距 + 可选的淡色虚线分隔,不做复杂的课间休息区域 +4. **Mock 优先**: 先完成 HTML Mock 确认视觉效果,再实现 Avalonia 代码 +5. **编辑器不变**: ClassScheduleComponentEditor 不需要修改 +6. **数据层不变**: ClassIslandScheduleDataService 和 Models 不需要修改 +7. **接口兼容**: IDesktopComponentWidget、ITimeZoneAwareComponentWidget、IComponentPlacementContextAware 接口实现不变 + +## 验证步骤 + +1. HTML Mock 在浏览器中展示效果满意 +2. Avalonia 项目编译通过 +3. 运行项目,课程表组件显示新布局 +4. 亮色/暗色主题切换正常 +5. 当前课程高亮和进度条正常 +6. 科目配色稳定(同一科目每次显示颜色一致) +7. 测试通过 diff --git a/.trae/documents/launcher-resx-i18n-plan.md b/.trae/documents/launcher-resx-i18n-plan.md new file mode 100644 index 0000000..ad3dcd7 --- /dev/null +++ b/.trae/documents/launcher-resx-i18n-plan.md @@ -0,0 +1,850 @@ +# 启动器 RESX 多语言适配实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 为 LanMountainDesktop.Launcher 引入 RESX 资源文件,实现启动器 UI 的多语言适配,消除所有硬编码中英文字符串。 + +**Architecture:** 在 Launcher 项目中创建 RESX 资源文件体系(默认 zh-CN + en-US/ja-JP/ko-KR),通过 .NET 内置 `ResourceManager` 机制实现本地化。启动时从主应用 `settings.json` 读取 `LanguageCode` 字段设置 `CultureInfo.CurrentUICulture`,AXAML 中使用 `x:Static` 引用资源,C# 代码中通过 `Strings.ResourceName` 强类型访问。 + +**Tech Stack:** .NET RESX 资源文件、Avalonia `x:Static` 标记扩展、`System.Globalization.CultureInfo` + +--- + +## 现状分析 + +### 问题概述 + +1. **启动器完全没有本地化支持**:所有 UI 字符串硬编码,中英文混杂严重 +2. **纯英文窗口**:SplashWindow、ErrorWindow、MultiInstancePromptWindow、DataLocationPromptWindow、LoadingDetailsWindow +3. **纯中文窗口**:OobeWindow、MigrationPromptWindow、UpdateWindow、ErrorDebugWindow、DevDebugWindow、PrivacyPolicyWindow +4. **启动器不读取主应用语言设置**:没有 `LanguageCode` 相关代码 +5. **硬编码字符串总量约 180+ 条**,分布在 11 个 AXAML 视图和 11 个 C# code-behind 文件中 + +### 方案选择:RESX vs JSON + +| 维度 | RESX(本方案) | JSON(主项目模式) | +|------|---------------|-------------------| +| 编译时安全 | ✅ 强类型 `Strings.KeyName` | ❌ 字符串键值 `L("key", "fallback")` | +| AXAML 集成 | ✅ `x:Static` 直接引用 | ❌ 需 code-behind 赋值 | +| 回退机制 | ✅ 内置(默认资源 → 特定文化) | ✅ 自定义 `fallback` 参数 | +| 新增语言 | 需添加 RESX 文件并重新编译 | 仅添加 JSON 文件 | +| AOT 兼容性 | ⚠️ 需额外配置 | ✅ 已验证 | +| 与主项目一致性 | ❌ 不同模式 | ✅ 一致 | + +**选择 RESX 的理由**:启动器是独立轻量进程,不需要运行时语言切换;强类型访问减少拼写错误;`x:Static` 比 code-behind 赋值更清晰;RESX 的内置回退机制足够满足启动器需求。 + +### AOT 兼容性说明 + +Launcher 项目支持 Native AOT 发布。RESX 的 `ResourceManager` 依赖反射,需要: +1. 在 `.csproj` 中添加 `` 确保资源不被修剪 +2. 在 AOT props 中添加 `TrimmerRootAssembly` 保留资源程序集 +3. 发布后进行 AOT 冒烟测试验证 + +--- + +## 文件结构规划 + +### 新增文件 + +| 文件 | 职责 | +|------|------| +| `Resources/Strings.resx` | 默认资源文件(zh-CN,回退资源) | +| `Resources/Strings.en-US.resx` | 英语资源 | +| `Resources/Strings.ja-JP.resx` | 日语资源 | +| `Resources/Strings.ko-KR.resx` | 韩语资源 | +| `Services/LanguagePreferenceService.cs` | 从 settings.json 读取 LanguageCode 并设置 CultureInfo | + +### 修改文件 + +| 文件 | 改动内容 | +|------|---------| +| `LanMountainDesktop.Launcher.csproj` | 添加 RESX 嵌入资源配置 | +| `LanMountainDesktop.Launcher.AOT.props` | 添加资源程序集修剪保留 | +| `Program.cs` | 启动时调用语言偏好初始化 | +| `Views/SplashWindow.axaml` | 替换硬编码字符串为 `x:Static` | +| `Views/SplashWindow.axaml.cs` | 替换 C# 硬编码字符串为 `Strings.XXX` | +| `Views/ErrorWindow.axaml` | 同上 | +| `Views/ErrorWindow.axaml.cs` | 同上 | +| `Views/MultiInstancePromptWindow.axaml` | 同上 | +| `Views/MultiInstancePromptWindow.axaml.cs` | 同上 | +| `Views/DataLocationPromptWindow.axaml` | 同上 | +| `Views/DataLocationPromptWindow.axaml.cs` | 同上 | +| `Views/LoadingDetailsWindow.axaml` | 同上 | +| `Views/LoadingDetailsWindow.axaml.cs` | 同上 | +| `Views/UpdateWindow.axaml` | 同上 | +| `Views/UpdateWindow.axaml.cs` | 同上 | +| `Views/ErrorDebugWindow.axaml` | 同上 | +| `Views/ErrorDebugWindow.axaml.cs` | 同上 | +| `Views/OobeWindow.axaml` | 同上 | +| `Views/OobeWindow.axaml.cs` | 同上 | +| `Views/MigrationPromptWindow.axaml` | 同上 | +| `Views/MigrationPromptWindow.axaml.cs` | 同上 | +| `Views/PrivacyPolicyWindow.axaml` | 同上 | +| `Views/PrivacyPolicyWindow.axaml.cs` | 同上 | +| `Views/DevDebugWindow.axaml` | 同上 | +| `Views/DevDebugWindow.axaml.cs` | 同上 | +| `Services/LauncherFlowCoordinator.cs` | 替换硬编码字符串 | +| `App.axaml.cs` | 替换预览模式硬编码字符串 | + +--- + +## RESX 键命名规范 + +采用 `ViewName_ElementDescription` 模式,PascalCase 分隔: + +- 窗口标题:`Splash_Title`、`Error_Title`、`MultiInstance_Title` +- 按钮文本:`Error_ButtonOpenLogs`、`Error_ButtonCopy`、`Error_ButtonRetry` +- 状态文本:`Splash_StatusInitializing`、`Loading_StatusPreparing` +- 描述文本:`DataLocation_DescSystemProfile`、`DataLocation_DescPortable` +- OOBE 步骤:`Oobe_StepWelcomeTitle`、`Oobe_StepAppearanceTitle` + +--- + +## 实施任务 + +### Task 1: 创建 RESX 基础设施 + +**Files:** +- Create: `LanMountainDesktop.Launcher/Resources/Strings.resx` +- Create: `LanMountainDesktop.Launcher/Resources/Strings.en-US.resx` +- Create: `LanMountainDesktop.Launcher/Resources/Strings.ja-JP.resx` +- Create: `LanMountainDesktop.Launcher/Resources/Strings.ko-KR.resx` +- Modify: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj` +- Modify: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.AOT.props` + +- [ ] **Step 1: 创建默认 RESX 文件(zh-CN 回退资源)** + +创建 `Resources/Strings.resx`,包含所有 180+ 条字符串的中文翻译。此文件同时作为回退资源和中文资源。 + +```xml + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + + + 阑山桌面 + 阑山桌面 + 正在初始化... + [调试模式] 启动画面预览 + + + 阑山桌面 + 启动器无法确认启动状态 + 阑山桌面未达到预期的启动状态。 + 启动恢复 + 您可以检查日志、等待当前进程或激活正在运行的桌面实例。 + 诊断详情 + 打开日志 + 复制 + 等待 + 退出 + 重试 + 激活 + [调试] 启动器错误 + 启动器找不到桌面可执行文件 + 在调试模式下选择另一个可执行文件、检查日志,或在修复部署路径后重试。 + 检查日志后重试,等待上一次启动尝试完全结束。 + 检查日志或退出。旧进程仍在运行时,启动器不会创建新的桌面进程。 + 启动仍在进行中 + 桌面进程仍在运行,启动器不会启动第二个实例。 + + + 阑山桌面 + 阑山桌面已在运行 + 启动器检测到已存在的桌面实例,未启动新进程。 + 重复启动 + 您当前的设置为显示此提示而不自动打开桌面。 + 未创建第二个主进程。 + 复制 + 关闭 + 打开桌面 + 现有主进程 PID: {0}\nShell 状态: {1}\n未创建第二个主进程。 + + + 选择数据保存位置 + 选择数据保存位置 + 选择启动器和桌面数据的存储位置。您可以稍后在设置中更改。 + 应用目录不可写入 + 当前安装目录需要管理员权限才能写入。数据将存储在系统用户目录中。 + 保存在系统用户目录(推荐) + 数据与当前 Windows 用户绑定,在应用重新安装和更新后保持完整。 + 保存在应用安装目录(便携模式) + 适用于便携安装。整个应用文件夹可以连同数据一起移动到另一台机器。 + 取消 + 确认 + 检测到已有的系统数据。选择便携模式将自动迁移当前数据。 + + + 阑山桌面 - 加载详情 + 正在启动阑山桌面 + 正在初始化... + 正在准备组件 + 加载项目 + 完成 + 加载时发生错误。 + 详情 + 取消 + 准备就绪 + 正在加载插件... + 正在加载组件... + 正在加载资源... + 正在加载数据... + 正在下载... + 正在处理... + 完成 + 插件 + 组件 + 资源 + 数据 + 网络 + 设置 + 系统 + 其他 + + + 阑山桌面 - 更新 + 阑山桌面 + 更新 + 正在更新,请稍候... + 更新完成 + 更新失败 + 更新过程中发生错误 + [调试模式] 更新页面 + 预览更新进度界面 + + + 调试模式 + 调试设置 + 开发模式 + 启用后自动扫描开发目录 + + + 应用路径 + 未选择 + 浏览... + 此功能仅供开发人员使用 + 取消 + 确定 + 选择阑山桌面主程序可执行文件 + + + 欢迎使用阑山桌面 + 欢迎使用阑山桌面 + 你的桌面,不止一面 + 开始使用 + 个性化你的桌面 + 选择你喜欢的主题样式,可随时在设置中更改 + 外观模式 + 浅色模式 + 深色模式 + 主题色 + 莫奈取色来源 + 从桌面壁纸取色 + 自定义图片取色 + 不使用莫奈取色 + 选择数据保存位置 + 保存在系统用户目录(推荐) + 数据与当前 Windows 用户绑定,在应用重新安装和更新后保持完整。 + 保存在应用安装目录(便携模式) + 适用于便携安装。整个应用文件夹可以连同数据一起移动到另一台机器。 + 无法保存到应用目录 + 当前安装目录需要管理员权限才能写入。数据将存储在系统用户目录中。 + 启动与展示 + 在任务栏显示主桌面窗口 + 以滑动方式显示主窗口 + 启动时使用淡入过渡 + 融合桌面与弹入手势 + 登录 Windows 时自动启动阑山桌面 + 信息与隐私 + 发送匿名崩溃报告 + 发送匿名使用统计 + 隐私追踪 ID + 同意 + 《阑山桌面遥测隐私数据收集协议》 + 返回 + 下一步 + 欢迎使用阑山桌面 + 你的桌面,不止一面 + + + 阑山桌面 - 版本迁移 + 检测到旧版本 + 检测到您的系统中安装了旧版本的阑山桌面(0.8.4)... + 版本: + 位置: + 类型: + 安装版 + 卸载旧版本不会影响新版本的使用,您的个人数据将保留。 + 查看位置 + 暂不处理 + 卸载旧版本 + + + 阑山桌面遥测隐私数据收集协议 + 阑山桌面遥测隐私数据收集协议 + 请仔细阅读以下协议内容,了解我们如何收集、使用和保护您的数据 + 关闭 + + + 开发调试窗口 + 启动画面 + 错误页面 + 更新页面 + OOBE页面 + 数据位置选择 + 启用功能 + 打开 + 全部设为查看模式 + 全部设为功能模式 + 关闭 + + + 设备较慢,仍在启动,请稍候。 + 桌面主进程仍在运行,Launcher 会继续等待,不会重复启动。 + + + 正在初始化... + 正在检查更新... + 正在检查插件... + 正在启动主程序... + 准备就绪 + [预览] 这是启动器错误窗口预览。 + 正在处理 {0}... + 正在连接到活跃的启动器... + +``` + +- [ ] **Step 2: 创建 en-US RESX 文件** + +创建 `Resources/Strings.en-US.resx`,包含所有字符串的英文翻译。结构与默认文件相同,仅 `` 内容为英文。 + +```xml + +LanMountain Desktop +LanMountain Desktop +Initializing... +Launcher could not confirm startup +LanMountain Desktop did not reach the expected startup state. + +``` + +- [ ] **Step 3: 创建 ja-JP RESX 文件** + +创建 `Resources/Strings.ja-JP.resx`,包含所有字符串的日语翻译。 + +- [ ] **Step 4: 创建 ko-KR RESX 文件** + +创建 `Resources/Strings.ko-KR.resx`,包含所有字符串的韩语翻译。 + +- [ ] **Step 5: 修改 .csproj 添加 RESX 配置** + +在 `LanMountainDesktop.Launcher.csproj` 的 `` 中添加: + +```xml + + + PublicResXFileCodeGenerator + Strings.Designer.cs + + +``` + +注意:使用 `PublicResXFileCodeGenerator` 而非 `ResXFileCodeGenerator`,生成 `public` 类以便 AXAML 的 `x:Static` 可以访问。 + +- [ ] **Step 6: 修改 AOT props 添加资源程序集保留** + +在 `LanMountainDesktop.Launcher.AOT.props` 的 AOT 修剪配置 `` 中添加: + +```xml + +``` + +- [ ] **Step 7: 运行构建验证 RESX 生成** + +Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug` +Expected: 构建成功,`Resources/Strings.Designer.cs` 自动生成 + +--- + +### Task 2: 创建语言偏好服务 + +**Files:** +- Create: `LanMountainDesktop.Launcher/Services/LanguagePreferenceService.cs` +- Modify: `LanMountainDesktop.Launcher/Program.cs` + +- [ ] **Step 1: 创建 LanguagePreferenceService** + +```csharp +using System.Globalization; +using System.Text.Json.Nodes; + +namespace LanMountainDesktop.Launcher.Services; + +internal static class LanguagePreferenceService +{ + public static string ResolveLanguageCode(string appRoot) + { + try + { + var dataLocationResolver = new DataLocationResolver(appRoot); + var settingsPath = HostAppSettingsOobeMerger.GetSettingsFilePath(dataLocationResolver.ResolveDataRoot()); + if (!File.Exists(settingsPath)) + { + return "zh-CN"; + } + + var root = JsonNode.Parse(File.ReadAllText(settingsPath))?.AsObject(); + if (root is not null && + root.TryGetPropertyValue("LanguageCode", out var node) && + node is JsonValue value && + value.TryGetValue(out var code) && + !string.IsNullOrWhiteSpace(code)) + { + return NormalizeLanguageCode(code); + } + } + catch + { + } + + return "zh-CN"; + } + + public static void ApplyLanguage(string languageCode) + { + var normalized = NormalizeLanguageCode(languageCode); + var culture = CultureInfo.GetCultureInfo(normalized); + CultureInfo.DefaultThreadCurrentCulture = culture; + CultureInfo.DefaultThreadCurrentUICulture = culture; + Thread.CurrentThread.CurrentCulture = culture; + Thread.CurrentThread.CurrentUICulture = culture; + } + + private static string NormalizeLanguageCode(string code) + { + return code.ToLowerInvariant() switch + { + "en-us" or "en" => "en-US", + "ja-jp" or "ja" => "ja-JP", + "ko-kr" or "ko" => "ko-KR", + _ => "zh-CN" + }; + } +} +``` + +- [ ] **Step 2: 在 Program.cs 中调用语言初始化** + +在 `Program.Main` 方法中,`BuildAvaloniaApp().StartWithClassicDesktopLifetime(args)` 之前添加语言初始化: + +```csharp +var appRoot = Commands.ResolveAppRoot(commandContext); +var languageCode = LanguagePreferenceService.ResolveLanguageCode(appRoot); +LanguagePreferenceService.ApplyLanguage(languageCode); +``` + +- [ ] **Step 3: 构建验证** + +Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug` +Expected: 构建成功 + +--- + +### Task 3: 替换 SplashWindow 硬编码字符串 + +**Files:** +- Modify: `LanMountainDesktop.Launcher/Views/SplashWindow.axaml` +- Modify: `LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs` + +- [ ] **Step 1: 在 SplashWindow.axaml 中添加 RESX 命名空间并替换字符串** + +在 `` 标签添加命名空间: +```xml +xmlns:res="clr-namespace:LanMountainDesktop.Launcher.Resources" +``` + +替换硬编码字符串: +- `Title="LanMountain Desktop"` → `Title="{x:Static res:Strings.Splash_Title}"` +- `Text="LanMountain Desktop"` (AppNameText) → `Text="{x:Static res:Strings.Splash_AppName}"` +- `Text="Initializing..."` (StatusText) → `Text="{x:Static res:Strings.Splash_StatusInitializing}"` + +注意:`VersionText` 的 `Text="0.0.0-dev (Administrate)"` 是动态设置的占位文本,保留原样(由 code-behind `SetVersionInfo` 方法设置)。 + +- [ ] **Step 2: 在 SplashWindow.axaml.cs 中替换 C# 硬编码字符串** + +将 `"[Debug Mode] Splash Preview"` 替换为 `Strings.Splash_DebugPreview`。 + +- [ ] **Step 3: 构建验证** + +Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug` +Expected: 构建成功 + +--- + +### Task 4: 替换 ErrorWindow 硬编码字符串 + +**Files:** +- Modify: `LanMountainDesktop.Launcher/Views/ErrorWindow.axaml` +- Modify: `LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs` + +- [ ] **Step 1: 在 ErrorWindow.axaml 中添加 RESX 命名空间并替换字符串** + +添加命名空间 `xmlns:res="clr-namespace:LanMountainDesktop.Launcher.Resources"` + +AXAML 替换: +- `Title="LanMountain Desktop"` → `Title="{x:Static res:Strings.Error_Title}"` +- `Text="Launcher could not confirm startup"` → `Text="{x:Static res:Strings.Error_TitleCannotConfirm}"` +- `Text="LanMountain Desktop did not reach..."` → `Text="{x:Static res:Strings.Error_MessageNotReached}"` +- `Title="Startup recovery"` → `Title="{x:Static res:Strings.Error_SuggestionTitle}"` +- `Message="You can inspect logs..."` → `Message="{x:Static res:Strings.Error_SuggestionMessage}"` +- `Header="Diagnostic details"` → `Header="{x:Static res:Strings.Error_DiagnosticHeader}"` +- `Text="Open Logs"` → `Text="{x:Static res:Strings.Error_ButtonOpenLogs}"` +- `Text="Copy"` → `Text="{x:Static res:Strings.Error_ButtonCopy}"` +- `Content="Wait"` → `Content="{x:Static res:Strings.Error_ButtonWait}"` +- `Text="Exit"` → `Text="{x:Static res:Strings.Error_ButtonExit}"` +- `Content="Retry"` → `Content="{x:Static res:Strings.Error_ButtonRetry}"` + +- [ ] **Step 2: 在 ErrorWindow.axaml.cs 中替换 C# 硬编码字符串** + +将所有硬编码字符串替换为 `Strings.XXX` 调用: +- `"LanMountain Desktop did not reach..."` → `Strings.Error_MessageNotReached` +- `"[Debug] Launcher error"` → `Strings.Error_DebugTitle` +- `"Launcher could not find the desktop executable"` → `Strings.Error_HostNotFoundTitle` +- `"Pick another executable..."` → `Strings.Error_HostNotFoundMessage` +- `"Launcher could not confirm startup"` → `Strings.Error_TitleCannotConfirm` +- `"Inspect logs, then retry..."` → `Strings.Error_GenericMessage` +- `"Inspect logs or exit..."` → `Strings.Error_RunningHostMessage` +- `"Retry"` → `Strings.Error_ButtonRetry` +- `"Activate"` → `Strings.Error_ButtonActivate` +- `"Wait"` → `Strings.Error_ButtonWait` +- `"Startup is still pending"` → `Strings.Error_PendingTitle` +- `"The desktop process is still running..."` → `Strings.Error_PendingMessage` + +- [ ] **Step 3: 构建验证** + +Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug` +Expected: 构建成功 + +--- + +### Task 5: 替换 MultiInstancePromptWindow 硬编码字符串 + +**Files:** +- Modify: `LanMountainDesktop.Launcher/Views/MultiInstancePromptWindow.axaml` +- Modify: `LanMountainDesktop.Launcher/Views/MultiInstancePromptWindow.axaml.cs` + +- [ ] **Step 1: 在 MultiInstancePromptWindow.axaml 中替换字符串** + +添加命名空间,替换: +- `Title="LanMountain Desktop"` → `Title="{x:Static res:Strings.MultiInstance_Title}"` +- `Text="LanMountain Desktop is already running"` → `Text="{x:Static res:Strings.MultiInstance_AlreadyRunning}"` +- `Text="Launcher found an existing..."` → `Text="{x:Static res:Strings.MultiInstance_AlreadyRunningMessage}"` +- `Title="Repeated launch"` → `Title="{x:Static res:Strings.MultiInstance_RepeatedLaunchTitle}"` +- `Message="Your current setting..."` → `Message="{x:Static res:Strings.MultiInstance_RepeatedLaunchMessage}"` +- `Text="No second Host process..."` → `Text="{x:Static res:Strings.MultiInstance_NoSecondProcess}"` +- `Text="Copy"` → `Text="{x:Static res:Strings.MultiInstance_ButtonCopy}"` +- `Text="Close"` → `Text="{x:Static res:Strings.MultiInstance_ButtonClose}"` +- `Text="Open desktop"` → `Text="{x:Static res:Strings.MultiInstance_ButtonOpenDesktop}"` + +- [ ] **Step 2: 在 MultiInstancePromptWindow.axaml.cs 中替换 C# 硬编码字符串** + +将格式化字符串替换为 `string.Format(Strings.MultiInstance_DetailsFormat, processId, shellState)` 等。 + +- [ ] **Step 3: 构建验证** + +Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug` +Expected: 构建成功 + +--- + +### Task 6: 替换 DataLocationPromptWindow 硬编码字符串 + +**Files:** +- Modify: `LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml` +- Modify: `LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml.cs` + +- [ ] **Step 1: 在 DataLocationPromptWindow.axaml 中替换字符串** + +替换所有 12 个硬编码字符串为 `x:Static` 引用。 + +- [ ] **Step 2: 在 DataLocationPromptWindow.axaml.cs 中替换 C# 硬编码字符串** + +将 `"Existing system data was detected..."` 替换为 `Strings.DataLocation_MigrateWarning`。 + +- [ ] **Step 3: 构建验证** + +Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug` +Expected: 构建成功 + +--- + +### Task 7: 替换 LoadingDetailsWindow 硬编码字符串 + +**Files:** +- Modify: `LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml` +- Modify: `LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml.cs` + +- [ ] **Step 1: 在 LoadingDetailsWindow.axaml 中替换字符串** + +替换所有硬编码字符串为 `x:Static` 引用。 + +- [ ] **Step 2: 在 LoadingDetailsWindow.axaml.cs 中替换 C# 硬编码字符串** + +替换 `GetStageDescription`、`GetItemDescription`、`GetTypeLabel` 方法中的硬编码字符串为 `Strings.XXX` 调用。 + +- [ ] **Step 3: 构建验证** + +Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug` +Expected: 构建成功 + +--- + +### Task 8: 替换 UpdateWindow 硬编码字符串 + +**Files:** +- Modify: `LanMountainDesktop.Launcher/Views/UpdateWindow.axaml` +- Modify: `LanMountainDesktop.Launcher/Views/UpdateWindow.axaml.cs` + +- [ ] **Step 1: 在 UpdateWindow.axaml 中替换字符串** + +替换 `"Update"` 为 `x:Static res:Strings.Update_StatusUpdate`。 + +- [ ] **Step 2: 在 UpdateWindow.axaml.cs 中替换 C# 硬编码字符串** + +替换 `"更新完成"`、`"更新失败"`、`"更新过程中发生错误"`、`"[调试模式] 更新页面"`、`"预览更新进度界面"` 为 `Strings.XXX` 调用。 + +- [ ] **Step 3: 构建验证** + +Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug` +Expected: 构建成功 + +--- + +### Task 9: 替换 ErrorDebugWindow 硬编码字符串 + +**Files:** +- Modify: `LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml` +- Modify: `LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml.cs` + +- [ ] **Step 1: 在 ErrorDebugWindow.axaml 中替换字符串** + +该窗口已使用中文,替换所有硬编码中文字符串为 `x:Static` 引用。 + +- [ ] **Step 2: 在 ErrorDebugWindow.axaml.cs 中替换 C# 硬编码字符串** + +替换 `"Select LanMountainDesktop host executable"` 和 `"Not selected"` 为 `Strings.DebugDebug_SelectExeDialog` 和 `Strings.DebugDebug_NotSelected`。 + +- [ ] **Step 3: 构建验证** + +Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug` +Expected: 构建成功 + +--- + +### Task 10: 替换 OobeWindow 硬编码字符串 + +**Files:** +- Modify: `LanMountainDesktop.Launcher/Views/OobeWindow.axaml` +- Modify: `LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs` + +这是最大的单个任务,OobeWindow 有约 42 个硬编码字符串。 + +- [ ] **Step 1: 在 OobeWindow.axaml 中替换字符串** + +添加命名空间,逐个替换所有硬编码中文字符串为 `x:Static` 引用。包括: +- 窗口标题、欢迎页文本 +- 外观设置页文本 +- 数据位置页文本 +- 启动展示页文本 +- 隐私页文本 +- 完成页文本 +- 导航按钮文本 + +- [ ] **Step 2: 在 OobeWindow.axaml.cs 中替换 C# 硬编码字符串(如有)** + +检查 code-behind 中是否有动态设置的硬编码字符串并替换。 + +- [ ] **Step 3: 构建验证** + +Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug` +Expected: 构建成功 + +--- + +### Task 11: 替换 MigrationPromptWindow 硬编码字符串 + +**Files:** +- Modify: `LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml` +- Modify: `LanMountainDesktop.Launcher/Views/MigrationPromptWindow.axaml.cs` + +- [ ] **Step 1: 在 MigrationPromptWindow.axaml 中替换字符串** + +替换所有硬编码中文字符串为 `x:Static` 引用。 + +- [ ] **Step 2: 在 MigrationPromptWindow.axaml.cs 中替换 C# 硬编码字符串(如有)** + +- [ ] **Step 3: 构建验证** + +Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug` +Expected: 构建成功 + +--- + +### Task 12: 替换 PrivacyPolicyWindow 硬编码字符串 + +**Files:** +- Modify: `LanMountainDesktop.Launcher/Views/PrivacyPolicyWindow.axaml` +- Modify: `LanMountainDesktop.Launcher/Views/PrivacyPolicyWindow.axaml.cs` + +- [ ] **Step 1: 在 PrivacyPolicyWindow.axaml 中替换字符串** + +替换标题、描述、关闭按钮等硬编码字符串。 + +- [ ] **Step 2: 在 PrivacyPolicyWindow.axaml.cs 中处理隐私政策正文** + +隐私政策正文(约 80 行 Markdown)目前硬编码在 C# 中。考虑: +- 方案 A:将 Markdown 正文也放入 RESX(支持多语言隐私政策) +- 方案 B:保留 Markdown 正文在 C# 中,仅替换窗口标题和按钮 + +推荐方案 A,将隐私政策 Markdown 正文放入 RESX 的 `Privacy_PolicyContent` 键中。 + +- [ ] **Step 3: 构建验证** + +Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug` +Expected: 构建成功 + +--- + +### Task 13: 替换 DevDebugWindow 硬编码字符串 + +**Files:** +- Modify: `LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml` +- Modify: `LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs` + +- [ ] **Step 1: 在 DevDebugWindow.axaml 中替换字符串** + +替换所有硬编码中文字符串为 `x:Static` 引用。 + +- [ ] **Step 2: 在 DevDebugWindow.axaml.cs 中替换 C# 硬编码字符串(如有)** + +- [ ] **Step 3: 构建验证** + +Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug` +Expected: 构建成功 + +--- + +### Task 14: 替换 LauncherFlowCoordinator 和 App.axaml.cs 硬编码字符串 + +**Files:** +- Modify: `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs` +- Modify: `LanMountainDesktop.Launcher/App.axaml.cs` + +- [ ] **Step 1: 在 LauncherFlowCoordinator.cs 中替换字符串** + +替换: +- `"设备较慢,仍在启动,请稍候。"` → `Strings.Coordinator_SlowDeviceMessage` +- `"桌面主进程仍在运行..."` → `Strings.Coordinator_RunningHostMessage` + +- [ ] **Step 2: 在 App.axaml.cs 中替换预览模式字符串** + +替换 `SimulateSplashPreviewAsync` 中的硬编码消息数组: +```csharp +var messages = new[] { Strings.Preview_SplashInitializing, Strings.Preview_SplashCheckingUpdates, Strings.Preview_SplashCheckingPlugins, Strings.Preview_SplashLaunchingHost, Strings.Preview_SplashReady }; +``` + +替换 `HandlePreviewCommand` 中的 `"[Preview] This is the launcher error window preview."` → `Strings.Preview_ErrorMessage` + +替换 `RunApplyUpdateWithWindowAsync` 中的硬编码字符串: +- `"Verifying update..."` → 使用 RESX 键 +- `"Applying plugin upgrades..."` → 使用 RESX 键 +- `"Cleaning up old deployments..."` → 使用 RESX 键 + +替换 `SimulateUpdatePreviewAsync` 中的 `$"Processing {stages[i]}..."` → `string.Format(Strings.Preview_UpdateProcessing, stages[i])` + +替换 `AttachToExistingCoordinatorAsync` 中的 `"Connecting to the active launcher..."` → `Strings.Preview_ActivationConnecting` + +- [ ] **Step 3: 构建验证** + +Run: `dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug` +Expected: 构建成功 + +--- + +### Task 15: 完整构建和运行验证 + +**Files:** 无新增/修改 + +- [ ] **Step 1: 完整解决方案构建** + +Run: `dotnet build LanMountainDesktop.slnx -c Debug` +Expected: 构建成功,无错误 + +- [ ] **Step 2: 运行启动器预览命令验证中文** + +Run: `dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- preview-splash` +Expected: 启动画面显示中文 + +- [ ] **Step 3: 验证英文模式** + +临时将 `LanguagePreferenceService.ResolveLanguageCode` 返回 `"en-US"` 后运行预览命令,验证英文显示。 + +- [ ] **Step 4: 运行测试** + +Run: `dotnet test LanMountainDesktop.slnx -c Debug` +Expected: 所有测试通过 + +--- + +### Task 16: AOT 发布冒烟测试 + +**Files:** 无新增/修改 + +- [ ] **Step 1: AOT 发布测试** + +Run: `dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Release -r win-x64 /p:PublishAot=true` +Expected: 发布成功 + +- [ ] **Step 2: 运行 AOT 发布产物验证** + +运行发布后的可执行文件,验证 RESX 资源正确加载。 + +--- + +## 实施顺序建议 + +1. **Task 1** (RESX 基础设施) → **Task 2** (语言偏好服务) — 必须首先完成 +2. **Task 3-9** (英文窗口) — 优先处理,解决用户提出的"只有英文"问题 +3. **Task 10-13** (中文窗口) — 次优先,完成完整 i18n 覆盖 +4. **Task 14** (服务层和 App) — 与 Task 3-13 并行或随后 +5. **Task 15-16** (验证) — 最后执行 + +## 风险与注意事项 + +1. **AOT 兼容性**:`ResourceManager` 在 Native AOT 下可能需要额外配置。如果 AOT 发布失败,需要添加 `DynamicDependency` 属性或使用 `System.Resources.Extensions` 包的源生成器。 +2. **OOBE 首次运行**:OOBE 在首次运行时 `settings.json` 不存在,此时 `LanguagePreferenceService` 会回退到 `zh-CN`。这是合理的行为。 +3. **`x:Static` 与 Avalonia CompiledBindings**:项目启用了 `AvaloniaUseCompiledBindingsByDefault`,需要确认 `x:Static` 在编译绑定模式下正常工作。如有问题,可在特定 AXAML 文件中添加 `x:CompileBindings="False"`。 +4. **RESX Designer.cs 生成**:确保 `.csproj` 中使用 `PublicResXFileCodeGenerator` 生成 `public` 类,否则 `x:Static` 无法访问。 +5. **隐私政策多语言**:隐私政策 Markdown 正文较长,放入 RESX 可能影响可读性。可考虑保留在 C# 中或使用独立资源文件。 diff --git a/.trae/documents/launcher_improved_plan_v2.md b/.trae/documents/launcher_improved_plan_v2.md index b9874c5..0541a50 100644 --- a/.trae/documents/launcher_improved_plan_v2.md +++ b/.trae/documents/launcher_improved_plan_v2.md @@ -154,7 +154,7 @@ │ │ │ 方案 2: 命名管道(推荐用于进度报告) │ │ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ Launcher 创建命名管道: \\.\pipe\LanMountainDesktop_Launcher │ │ +│ │ [历史方案] Launcher 创建命名管道: \\.\pipe\LanMountainDesktop_Launcher │ │ │ │ 主程序连接并发送进度消息 │ │ │ │ │ │ │ │ 消息格式: JSON │ │ @@ -289,7 +289,7 @@ public static class LauncherIpcConstants #### 4. 实现 IPC 服务端 -**新建文件**: `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs` +**历史方案,已废弃**: `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs` ```csharp using System.IO.Pipes; @@ -428,7 +428,7 @@ public async Task RunAsync() #### 6. 实现 IPC 客户端 -**新建文件**: `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs` +**历史方案,已废弃**: `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs` ```csharp using System.IO.Pipes; @@ -672,8 +672,8 @@ public class UpdateInstallationService ### 新增文件 1. `LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs` - IPC 契约 -2. `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs` - IPC 服务端 -3. `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs` - IPC 客户端 +2. `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs` - 历史启动进度 IPC 服务端,已由公共 IPC 通知替代 +3. `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs` - 历史启动进度 IPC 客户端,已由公共 IPC 通知替代 4. `LanMountainDesktop.Launcher/Services/Update/UpdateInstallationService.cs` - 更新安装 ### 删除文件 @@ -715,3 +715,11 @@ public class UpdateInstallationService - [ ] GitHub Actions 打包成功 - [ ] 安装程序图标正常 - [ ] 快捷方式图标正常 + +## 2026 Multi-instance Policy Update + +- The old launcher progress pipe is historical only; current startup progress uses public IPC. +- Launcher now reads Host `settings.json` for `MultiInstanceLaunchBehavior` before normal launch. +- Existing Host behavior is policy-driven: restart app, open desktop silently, prompt only, or notify and open desktop. +- Host no longer owns the single-instance listener or already-running prompt; repeated-launch policy lives in Launcher. +- The repeated-launch prompt is a Fluent Launcher window; Host public IPC only exposes execution actions such as activate, restart, and exit. diff --git a/.trae/documents/update-settings-redesign.md b/.trae/documents/update-settings-redesign.md new file mode 100644 index 0000000..c049ec5 --- /dev/null +++ b/.trae/documents/update-settings-redesign.md @@ -0,0 +1,212 @@ +# 更新设置界面重设计实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将更新设置页面从丑陋的卡片堆叠布局重设计为遵循 Fluent Design 的 FASettingsExpander 列表布局,与项目其他设置页面保持视觉一致性。 + +**Architecture:** 移除所有 `Border.settings-section-card` 包裹,改用 `FASettingsExpander` + `IconText` 分节标题 + `Separator` 分隔线的统一模式。操作按钮改为仅显示当前可用操作。版本信息改为 `FASettingsExpanderItem` 行项目展示。ViewModel 层新增 `ActiveActions` 计算属性来驱动按钮可见性。 + +**Tech Stack:** Avalonia UI 11, FluentAvalonia 2.x, CommunityToolkit.Mvvm + +--- + +## 当前状态分析 + +### 现有文件 + +| 文件 | 职责 | +|------|------| +| `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml` | 更新页面 AXAML 布局 | +| `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml.cs` | 代码隐藏 | +| `LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs` | 视图模型 | +| `LanMountainDesktop/Styles/SettingsCardStyles.axaml` | 通用设置样式 | +| `LanMountainDesktop/Controls/IconText.axaml(.cs)` | 分节标题控件 | +| `LanMountainDesktop.Shared.Contracts/Update/UpdateState.cs` | UpdatePhase 枚举和扩展方法 | + +### 核心问题 + +1. **4 个 `Border.settings-section-card` 卡片**:状态卡、版本信息卡、进度卡、操作卡,每个都带边框+阴影+圆角,视觉零碎 +2. **FAInfoBar 嵌套在卡片内**:冗余的容器层级 +3. **7 个按钮 3×3 网格**:大量按钮在当前阶段不可用但仍然占据空间 +4. **与其他设置页面风格不一致**:GeneralSettingsPage、AppearanceSettingsPage 等全部使用 `FASettingsExpander` 列表 + +### 参考基准 + +- [GeneralSettingsPage.axaml](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml):`IconText` 分节标题 → `FASettingsExpander` 列表 → `Separator` 分隔 +- [AppearanceSettingsPage.axaml](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Views/SettingsPages/AppearanceSettingsPage.axaml):同上模式 +- [AboutSettingsPage.axaml](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml):`FAInfoBar` 用于静态信息展示 +- Windows 11 设置 > Windows Update:顶部状态区 + 进度条 + 操作按钮,下方展开区展示详情 + +--- + +## 设计决策 + +| 决策项 | 选择 | 理由 | +|--------|------|------| +| 布局模式 | FASettingsExpander 列表 | 与其他设置页面统一,Fluent Design 原生控件 | +| 按钮策略 | 仅显示可用操作 | 简洁、不混乱,Windows 11 更新页面也是此模式 | +| 版本信息 | FASettingsExpanderItem 行项目 | 每行一个信息,干净可扫描 | +| 进度展示 | 内嵌在状态 Expander 内 | 进度是状态的一部分,不应独立成卡 | +| 偏好设置 | 保留 FASettingsExpander | 已经是正确模式,微调即可 | + +--- + +## 新布局结构 + +``` +ScrollViewer +└── StackPanel (settings-page-container settings-page-animated) + ├── TextBlock (settings-section-title: "更新") + ├── TextBlock (settings-section-description: 描述文字) + │ + ├── IconText (Icon="ArrowSync", Text="更新状态") + │ + ├── FASettingsExpander "检查更新" (IsClickEnabled=True, Command=CheckCommand) + │ ├── IconSource: ArrowSync 图标 + │ └── Footer: Button "检查更新" (仅 CanCheck 时可见) + │ + ├── FASettingsExpander "更新进度" (IsVisible=IsBusy||IsProgressVisible||IsPaused) + │ ├── IconSource: FAProgressRing / 对应阶段图标 + │ ├── Footer: PhaseText + ProgressFraction + │ └── FASettingsExpanderItem + │ ├── ProgressBar (ProgressFraction) + │ ├── ProgressDetail 文字 + │ └── 操作按钮行 (仅可用操作) + │ ├── Button "下载" (CanDownload) + │ ├── Button "安装" (CanInstall) + │ ├── Button "暂停" (CanPause) + │ ├── Button "继续" (CanResume) + │ ├── Button "回滚" (CanRollback) + │ └── Button "取消" (CanCancel) + │ + ├── FASettingsExpander "暂停" (IsVisible=IsPaused) + │ └── FAInfoBar (PausedBadgeText + PausedHintText) + │ + ├── Separator (settings-separator) + │ + ├── IconText (Icon="Info", Text="版本信息") + │ + ├── FASettingsExpander "当前版本" (IsClickEnabled=False) + │ ├── IconSource: 版本图标 + │ └── Footer: CurrentVersionText + │ + ├── FASettingsExpander "最新版本" (IsClickEnabled=False) + │ ├── IconSource: 下载图标 + │ └── Footer: LatestVersionText (或 "已是最新") + │ + ├── FASettingsExpander "发布时间" (IsClickEnabled=False) + │ ├── IconSource: 日历图标 + │ └── Footer: PublishedAtText + │ + ├── FASettingsExpander "上次检查" (IsClickEnabled=False) + │ ├── IconSource: 时钟图标 + │ └── Footer: LastCheckedText + │ + ├── FASettingsExpander "更新类型" (IsClickEnabled=False) + │ ├── IconSource: 标签图标 + │ └── Footer: UpdateTypeText + │ + ├── Separator (settings-separator) + │ + ├── IconText (Icon="Settings", Text="更新偏好") + │ + └── FASettingsExpander "更新偏好" (IsExpanded=True) + ├── IconSource: 设置齿轮图标 + ├── FASettingsExpanderItem "更新频道" + │ └── Footer: ComboBox (stable/preview) + ├── FASettingsExpanderItem "下载源" + │ └── Footer: ComboBox (plonds/github/proxy) + ├── FASettingsExpanderItem "更新模式" + │ └── Footer: ComboBox (manual/confirm/silent) + └── FASettingsExpanderItem "下载线程数" + └── Footer: Slider + TextBlock +``` + +--- + +## Proposed Changes + +### Task 1: 重写 UpdateSettingsPage.axaml 布局 + +**Files:** +- Modify: `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml` + +**What:** 完全重写 AXAML,将 4 个 `Border.settings-section-card` 替换为 `FASettingsExpander` 列表布局。 + +**Key changes:** +1. 移除所有 `Border.settings-section-card` 包裹 +2. 使用 `controls:IconText` 做分节标题(与 GeneralSettingsPage 一致) +3. 状态区域:`FASettingsExpander` + `IsClickEnabled=True` + `Command=CheckCommand`,Footer 放检查按钮 +4. 进度区域:`FASettingsExpander` 内嵌 ProgressBar + 操作按钮,仅 `IsBusy||IsProgressVisible||IsPaused` 时可见 +5. 版本信息:每个字段一个 `FASettingsExpander`,Footer 直接显示值(参考 Windows 11 更新页面的行项目模式) +6. 偏好设置:保留 `FASettingsExpander` + `FASettingsExpanderItem` 模式,但将 TextBox 改为 ComboBox(更符合 Fluent 规范) +7. 使用 `Separator classes="settings-separator"` 分隔三大区域 + +**Why:** 与项目其他设置页面统一风格,遵循 Fluent Design,消除卡片堆叠的视觉噪音。 + +**How:** +- 参照 GeneralSettingsPage.axaml 的布局模式 +- 参照 AppearanceSettingsPage.axaml 的 FASettingsExpander 使用方式 +- 参照 AboutSettingsPage.axaml 的 FAInfoBar 使用方式 + +### Task 2: 更新 ViewModel — 添加 ComboBox 数据源和按钮可见性属性 + +**Files:** +- Modify: `LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs` + +**What:** +1. 将更新频道、下载源、更新模式从 `TextBox` 绑定改为 `ComboBox` 绑定,添加 `ObservableCollection` 类型的数据源属性 +2. 添加 `IsProgressSectionVisible` 计算属性(`IsBusy || IsProgressVisible || IsPaused`) +3. 添加 `IsUpdateAvailableSectionVisible` 计算属性(`IsUpdateAvailable`) +4. 添加 `IsStatusInfoVisible` 计算属性(有 StatusMessage 且非空闲时) +5. 移除不再需要的独立按钮文本属性(CheckButtonText 保留,其他按钮文本属性保留但仅在可见时使用) + +**Why:** ComboBox 比 TextBox 更适合有限选项的输入,且与 GeneralSettingsPage 的模式一致。按钮可见性属性让 AXAML 可以用 `IsVisible` 绑定控制按钮显示。 + +**How:** +- 参考 GeneralSettingsPageViewModel 中 SelectionOption 的使用方式 +- 在 `OnCurrentPhaseChanged` 中触发新属性的 OnPropertyChanged + +### Task 3: 将偏好设置 TextBox 替换为 ComboBox + +**Files:** +- Modify: `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml` (在 Task 1 中一并完成) +- Modify: `LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs` (在 Task 2 中一并完成) + +**What:** 将更新频道、下载源、更新模式三个 `TextBox` 替换为 `ComboBox`,使用 `SelectionOption` 数据模板。 + +**Why:** 有限选项应使用 ComboBox 而非自由文本输入,这是 Fluent Design 的基本规范,也与 GeneralSettingsPage 中的语言/时区选择一致。 + +### Task 4: 构建验证 + +**Files:** +- 无新文件 + +**What:** 运行 `dotnet build` 确保编译通过,检查 AXAML 绑定是否正确。 + +--- + +## Assumptions & Decisions + +1. **不修改 UpdateOrchestrator 和 UpdateState** — 只改 UI 层和 ViewModel 的展示逻辑,不改底层更新引擎 +2. **不修改 SettingsCardStyles.axaml** — 通用样式保持不变,移除的是 UpdateSettingsPage 对它的使用 +3. **保留所有 ViewModel 属性** — 即使某些属性在新布局中不再直接使用(如独立的 ActionsTitle),也保留以避免破坏本地化系统 +4. **ComboBox 选项硬编码在 ViewModel** — 参考 GeneralSettingsPageViewModel 的 SelectionOption 模式 +5. **进度区域在空闲时隐藏** — 不显示空的进度条,只在有活动时展示 +6. **FAInfoBar 仅用于暂停/错误提示** — 不再嵌套在卡片内,直接放在 FASettingsExpanderItem 内 + +--- + +## Verification Steps + +1. `dotnet build LanMountainDesktop.slnx -c Debug` 编译通过 +2. 运行应用,导航到设置 > 更新页面,验证: + - 页面布局与 GeneralSettingsPage 风格一致 + - 无圆角矩形卡片包裹 + - 检查更新按钮可用 + - 进度区域在空闲时隐藏 + - 版本信息以行项目形式展示 + - 偏好设置使用 ComboBox + - 操作按钮仅显示当前可用的 +3. 点击「检查更新」,验证状态变化和进度展示 +4. 验证偏好设置的 ComboBox 选择能正确保存和加载 diff --git a/.trae/documents/weather-widget-material-redesign.md b/.trae/documents/weather-widget-material-redesign.md new file mode 100644 index 0000000..cad8cfb --- /dev/null +++ b/.trae/documents/weather-widget-material-redesign.md @@ -0,0 +1,559 @@ +# 天气组件 Material Design 重设计计划 + +> **目标:** 全面重构阑山桌面天气组件的视觉设计,遵循 Material Design 3 规范,参考 Google Weather、几何天气、Breez 天气和柠檬天气的设计语言。 + +--- + +## 当前状态分析 + +### 现有组件 +1. **WeatherWidget** - 基础天气(温度+天气状况+位置) +2. **ExtendedWeatherWidget** - 扩展天气(含指标、逐小时、逐日预报) +3. **HourlyWeatherWidget** - 逐小时天气 +4. **MultiDayWeatherWidget** - 多日天气 +5. **WeatherClockWidget** - 天气时钟 + +### 现有问题 +- 排版层次不清晰,文字大小对比不够 +- 布局过于紧凑,缺乏呼吸感 +- 内部卡片使用简单纯色背景,缺乏 Material 风格 +- 背景场景和前景内容缺乏深度分离 +- 圆角和间距不统一 + +### 现有视觉系统 +- 4套调色板:Google(默认)、Geometric、Breezy、LemonFlutter +- 动态背景场景:MaterialWeatherSceneControl 绘制渐变+装饰 +- 图标系统:WeatherIconView + WeatherIconAssetResolver + +--- + +## 设计方向 + +### 核心原则 +1. **Material Design 3** - 使用 M3 的排版、颜色、间距和形状规范 +2. **信息层级清晰** - 大字体温度、次要信息弱化 +3. **呼吸感** - 合理的间距和留白 +4. **深度感** - 前景卡片与背景场景分离 +5. **圆角一致性** - 遵循 DesignCornerRadius 规范 + +### 参考风格 +- **Google Weather** - 大字体温度、清晰层级、圆角卡片、柔和渐变 +- **几何天气** - 几何装饰、现代感 +- **Breez** - 清新留白、柔和色彩 +- **柠檬天气** - 活泼明亮 + +--- + +## 具体改动计划 + +### Task 1: 优化 MaterialWeatherPalette 和调色板系统 + +**文件:** `LanMountainDesktop/Views/Components/MaterialWeatherVisualTheme.cs` + +**改动:** +- 调整所有调色板的对比度,确保文字可读性 +- 优化背景渐变色彩,更加柔和自然 +- 统一文字主色和次色的对比度比例 +- 为每个风格增加 `SurfaceColor` 和 `SurfaceVariantColor` 用于卡片背景 + +**当前调色板字段:** +```csharp +public sealed record MaterialWeatherPalette( + Color BackgroundTop, + Color BackgroundBottom, + Color PrimaryShape, + Color SecondaryShape, + Color AccentShape, + Color TextPrimary, + Color TextSecondary, + Color SurfaceTint, + Color OverlayTint); +``` + +**新增字段:** +```csharp + Color SurfaceColor, // 卡片表面色(低透明度白色/黑色) + Color SurfaceVariantColor, // 变体表面色 + Color OutlineColor // 分割线/边框色 +``` + +--- + +### Task 2: 重构 WeatherWidget(基础天气组件) + +**文件:** +- `LanMountainDesktop/Views/Components/WeatherWidget.axaml` +- `LanMountainDesktop/Views/Components/WeatherWidget.axaml.cs` + +**设计目标:** +- 大字体温度显示(类似 Google Weather) +- 天气状况文字清晰可读 +- 位置和温度范围弱化显示 +- 图标与文字对齐优化 + +**XAML 改动:** +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +**CS 改动:** +- 调整响应式布局的字体缩放比例 +- 更新颜色绑定使用新的调色板字段 + +--- + +### Task 3: 重构 ExtendedWeatherWidget(扩展天气组件) + +**文件:** +- `LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml` +- `LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs` + +**设计目标:** +- 顶部区域:位置+温度+图标横向排列 +- 指标区域:使用 Material 3 风格的标签卡片 +- 逐小时预报:水平滚动卡片,时间+图标+温度 +- 逐日预报:列表式布局,日期+图标+高低温 + +**XAML 改动:** +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +**CS 改动:** +- `CreateMetric` 方法:使用圆角卡片,Material 3 风格标签 +- `BuildHourlyItems` 方法:改进卡片样式,统一圆角 +- `BuildDailyItems` 方法:改进卡片样式,统一圆角 + +--- + +### Task 4: 重构 HourlyWeatherWidget(逐小时天气组件) + +**文件:** +- `LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml` +- `LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs` + +**设计目标:** +- 顶部简洁信息栏 +- 逐小时预报使用 Material 卡片风格 +- 时间、图标、温度垂直排列 + +**XAML 改动:** +```xml + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +--- + +### Task 5: 重构 MultiDayWeatherWidget(多日天气组件) + +**文件:** +- `LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml` +- `LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs` + +**设计目标:** +- 左侧:当前天气信息(图标+温度+状况+位置) +- 右侧:多日预报列表,使用行式布局 + +**XAML 改动:** +```xml + + + + + + + + + + + + + + + + + + + + + + +``` + +--- + +### Task 6: 重构 WeatherClockWidget(天气时钟组件) + +**文件:** +- `LanMountainDesktop/Views/Components/WeatherClockWidget.axaml` +- `LanMountainDesktop/Views/Components/WeatherClockWidget.axaml.cs` + +**设计目标:** +- 左侧:大字体时间+日期 +- 右侧:天气图标+温度+状况 +- 信息层级清晰 + +**XAML 改动:** +```xml + + + + + + + + + + + + + + + + + + + + + + +``` + +--- + +### Task 7: 更新 ExtendedWeatherWidget 的代码后置文件 + +**文件:** `LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs` + +**改动:** +- `CreateMetric` 方法改进: + - 使用 `DesignCornerRadiusSm` 圆角 + - 使用新的 `SurfaceColor` 作为卡片背景 + - 优化字体大小和间距 + +- `BuildHourlyItems` 方法改进: + - 使用 `DesignCornerRadiusSm` 圆角 + - 使用 `SurfaceColor` 作为卡片背景 + - 时间、图标、温度垂直排列,居中对齐 + +- `BuildDailyItems` 方法改进: + - 使用 `DesignCornerRadiusSm` 圆角 + - 使用 `SurfaceColor` 作为卡片背景 + - 日期、图标、高低温垂直排列 + +--- + +### Task 8: 更新 HourlyWeatherWidget 的代码后置文件 + +**文件:** `LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs` + +**改动:** +- `CreateChip` 方法改进: + - 使用 `DesignCornerRadiusSm` 圆角 + - 使用 `SurfaceColor` 作为卡片背景 + - 优化垂直排列的间距 + +--- + +### Task 9: 更新 MultiDayWeatherWidget 的代码后置文件 + +**文件:** `LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs` + +**改动:** +- `CreateRow` 方法改进: + - 添加底部分割线(除最后一行) + - 优化列间距和对齐 + - 高低温使用不同透明度区分 + +--- + +### Task 10: 更新 MaterialWeatherVisualTheme 调色板 + +**文件:** `LanMountainDesktop/Views/Components/MaterialWeatherVisualTheme.cs` + +**改动:** +- 为 `MaterialWeatherPalette` 添加新字段: + - `SurfaceColor` - 用于卡片表面 + - `SurfaceVariantColor` - 用于变体表面 + - `OutlineColor` - 用于分割线 + +- 更新所有调色板生成方法: + - `ResolveGooglePalette` + - `ResolveGeometricPalette` + - `ResolveBreezyPalette` + - `ResolveLemonPalette` + +- 每个调色板需要为白天/夜晚模式提供合适的 SurfaceColor: + - 白天:低透明度白色(如 `#14FFFFFF`) + - 夜晚:低透明度黑色(如 `#1A000000`) + +--- + +### Task 11: 构建和测试 + +**命令:** +```bash +dotnet build LanMountainDesktop.slnx -c Debug +dotnet test LanMountainDesktop.slnx -c Debug +``` + +**验证清单:** +- [ ] 所有天气组件正常编译 +- [ ] 运行时无异常 +- [ ] 4套视觉风格正常切换 +- [ ] 响应式布局正常工作 +- [ ] 圆角资源正确应用 + +--- + +## 文件改动汇总 + +| 文件 | 改动类型 | 说明 | +|------|---------|------| +| `MaterialWeatherVisualTheme.cs` | 修改 | 添加 SurfaceColor 等字段,更新所有调色板 | +| `WeatherWidget.axaml` | 修改 | 重构布局,优化排版 | +| `WeatherWidget.axaml.cs` | 修改 | 调整响应式布局和颜色绑定 | +| `ExtendedWeatherWidget.axaml` | 修改 | 重构布局,添加卡片容器 | +| `ExtendedWeatherWidget.axaml.cs` | 修改 | 改进卡片创建方法 | +| `HourlyWeatherWidget.axaml` | 修改 | 重构布局,添加卡片容器 | +| `HourlyWeatherWidget.axaml.cs` | 修改 | 改进卡片创建方法 | +| `MultiDayWeatherWidget.axaml` | 修改 | 重构布局,添加卡片容器 | +| `MultiDayWeatherWidget.axaml.cs` | 修改 | 改进行创建方法 | +| `WeatherClockWidget.axaml` | 修改 | 重构布局,优化排版 | +| `WeatherClockWidget.axaml.cs` | 修改 | 调整响应式布局 | + +--- + +## 设计规范检查清单 + +- [ ] 所有组件根容器使用 `DesignCornerRadiusComponent` +- [ ] 内部卡片使用 `DesignCornerRadiusMd` 或 `DesignCornerRadiusSm` +- [ ] 不使用硬编码圆角值 +- [ ] 文字对比度符合 VISUAL_SPEC 要求 +- [ ] 间距使用一致的倍数(4px 基线) +- [ ] 字体层级:温度(64-72px) > 状况(16-18px) > 位置/范围(12-13px) diff --git a/.trae/documents/weather-widget-visual-redesign.md b/.trae/documents/weather-widget-visual-redesign.md new file mode 100644 index 0000000..3949b5e --- /dev/null +++ b/.trae/documents/weather-widget-visual-redesign.md @@ -0,0 +1,342 @@ +# 天气组件视觉重构 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 彻底重构阑山桌面天气系列组件的背景视觉和文字排版,为每种图标风格(Google Weather / Geometric / Breezy / Lemon)提供独立的背景配色和视觉质感,参考各天气 App 的 Material Design 风格,实现几何质感+柔和渐变+层次分明的排版。 + +**Architecture:** 保留现有数据层(WeatherWidgetBase、WeatherSnapshot、WeatherIconAssetResolver)和组件注册机制不变。核心改动:1) 将 `MaterialWeatherVisualTheme.ResolvePalette()` 扩展为按 styleId 分派不同配色方案;2) 重构 `MaterialWeatherSceneControl` 为按 styleId 渲染不同背景风格;3) 改进各天气 Widget 的文字排版层次。先创建 HTML Mock 验证视觉效果。 + +**Tech Stack:** Avalonia UI (XAML + C# code-behind)、HTML/CSS (Mock 预览) + +--- + +## 当前状态分析 + +### 现有天气组件体系 +5 个天气组件,全部继承自 `WeatherWidgetBase`: + +| 组件 | 文件 | 功能 | +|------|------|------| +| WeatherWidget | `WeatherWidget.axaml(.cs)` | 基础天气:温度+状况+图标+位置 | +| WeatherClockWidget | `WeatherClockWidget.axaml(.cs)` | 天气+时钟 | +| ExtendedWeatherWidget | `ExtendedWeatherWidget.axaml(.cs)` | 扩展天气:含指标/小时/多日预报 | +| HourlyWeatherWidget | `HourlyWeatherWidget.axaml(.cs)` | 逐小时天气 | +| MultiDayWeatherWidget | `MultiDayWeatherWidget.axaml(.cs)` | 多日天气 | + +### 核心问题 + +1. **背景与图标风格脱钩**: `MaterialWeatherVisualTheme.ResolvePalette()` 只返回一套配色,与 `WeatherVisualStyleId`(GoogleWeatherV4/Geometric/Breezy/LemonFlutter)完全无关。切换图标风格时背景不变。 +2. **背景视觉单调**: `MaterialWeatherSceneControl` 只有一种手绘几何风格(椭圆+云+雨滴),质感差,缺乏各 App 的特色。 +3. **文字排版粗糙**: 温度数字不够大,信息层次不分明,指标用纯文字堆叠,预报区域无卡片样式。 +4. **半透明遮罩硬编码**: 所有组件都覆盖 `` 等硬编码遮罩,不随风格变化。 + +### 各天气 App 风格特征 + +**Google Weather (v4)**: +- 背景:大面积柔和蓝白渐变,晴天偏暖黄蓝,雨天偏深蓝灰 +- 装饰:极简,几乎无几何装饰,纯靠渐变色彩表现天气氛围 +- 排版:温度超大(72px+),天气状况中等,位置小字 + +**Geometric Weather (几何天气)**: +- 背景:深色系渐变(深蓝/深紫/深灰),搭配半透明几何圆形装饰 +- 装饰:大面积半透明圆形叠加,营造深度感 +- 排版:紧凑信息密度,指标用小标签 + +**Breezy Weather (微风天气)**: +- 背景:清新渐变(浅蓝/浅绿/浅紫),比 Geometric 更明亮 +- 装饰:柔和波浪线条 + 少量几何装饰,Material Design 风格 +- 排版:卡片式预报,圆角芯片 + +**Lemon Weather (柠檬天气)**: +- 背景:暖色系渐变(橙黄/粉紫/暖蓝),柠檬2偏扁平,柠檬3偏Material +- 装饰:天气场景装饰(太阳光芒/云朵轮廓/雨丝),更有场景感 +- 排版:温度超大,天气图标突出 + +--- + +## 设计方案 + +### 视觉论文 (Visual Thesis) +每种图标风格拥有独特的背景渐变配色和几何装饰语言——Google 纯净渐变、Geometric 深色几何、Breezy 清新波浪、Lemon 暖色场景——配合超大温度数字和层次分明的排版,在桌面小组件空间内实现 Material Design 的几何质感。 + +### 配色方案设计 + +每种风格 × 每种天气条件 × 昼夜 = 独立配色。以下为关键配色定义: + +#### Google Weather 风格 +| 天气 | 白天 Top→Bottom | 夜晚 Top→Bottom | +|------|----------------|----------------| +| Clear | #4FC3F7 → #B3E5FC | #0D47A1 → #1A237E | +| PartlyCloudy | #81D4FA → #E1F5FE | #1565C0 → #283593 | +| Cloudy | #90A4AE → #CFD8DC | #37474F → #455A64 | +| Rain | #78909C → #B0BEC5 | #263238 → #37474F | +| Storm | #546E7A → #78909C | #1A1A2E → #263238 | +| Snow | #E1F5FE → #FFFFFF | #1A237E → #283593 | +| Fog/Haze | #B0BEC5 → #ECEFF1 | #455A64 → #546E7A | + +#### Geometric 风格 +| 天气 | 白天 Top→Bottom | 夜晚 Top→Bottom | +|------|----------------|----------------| +| Clear | #1A237E → #3949AB | #0A0E27 → #1A1A3E | +| PartlyCloudy | #283593 → #5C6BC0 | #0D1033 → #1E1E4A | +| Cloudy | #37474F → #607D8B | #1A1A2E → #2D2D44 | +| Rain | #1A237E → #3F51B5 | #0A0E27 → #1A1A3E | +| Storm | #1A1A2E → #3F51B5 | #050510 → #1A1A2E | +| Snow | #E8EAF6 → #C5CAE9 | #1A237E → #283593 | +| Fog/Haze | #455A64 → #78909C | #1A1A2E → #37474F | + +#### Breezy 风格 +| 天气 | 白天 Top→Bottom | 夜晚 Top→Bottom | +|------|----------------|----------------| +| Clear | #4DD0E1 → #80DEEA | #006064 → #00838F | +| PartlyCloudy | #4FC3F7 → #B2EBF2 | #00695C → #00897B | +| Cloudy | #80CBC4 → #B2DFDB | #37474F → #546E7A | +| Rain | #4DB6AC → #80CBC4 | #004D40 → #00695C | +| Storm | #26A69A → #4DB6AC | #1A1A2E → #004D40 | +| Snow | #E0F7FA → #FFFFFF | #006064 → #00838F | +| Fog/Haze | #80CBC4 → #E0F7FA | #37474F → #546E7A | + +#### Lemon 风格 +| 天气 | 白天 Top→Bottom | 夜晚 Top→Bottom | +|------|----------------|----------------| +| Clear | #FFB74D → #FFF176 | #1A237E → #311B92 | +| PartlyCloudy | #FF8A65 → #FFCC80 | #283593 → #4A148C | +| Cloudy | #BCAAA4 → #D7CCC8 | #37474F → #4E342E | +| Rain | #90A4AE → #B0BEC5 | #1A1A2E → #311B92 | +| Storm | #78909C → #90A4AE | #0D0D1A → #1A1A2E | +| Snow | #FFF9C4 → #FFFFFF | #1A237E → #311B92 | +| Fog/Haze | #D7CCC8 → #EFEBE9 | #4E342E → #5D4037 | + +### 排版改进方案 + +1. **温度超大化**: 温度字号从 56-58px 提升到 64-72px(基础组件),形成视觉锚点 +2. **层次分明**: 温度 → 天气状况 → 位置/指标,字号递减,透明度递减 +3. **指标标签化**: 湿度/风速/AQI 用半透明圆角标签展示,而非纯文字 +4. **预报芯片化**: 小时/每日预报用圆角半透明芯片卡片 +5. **图标间距**: 天气图标与文字之间增加 8-12px 间距 + +--- + +## 文件变更清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `Views/Components/MaterialWeatherVisualTheme.cs` | 修改 | 扩展 ResolvePalette 支持 styleId 分派,新增4套风格配色 | +| `Views/Components/MaterialWeatherSceneControl.cs` | 修改 | 按 styleId 渲染不同背景风格(纯渐变/深色几何/清新波浪/暖色场景) | +| `Views/Components/WeatherWidgetBase.cs` | 修改 | 传递 styleId 到 SceneControl.Apply(),移除硬编码遮罩 | +| `Views/Components/WeatherWidget.axaml` | 修改 | 改进排版层次,移除硬编码遮罩 | +| `Views/Components/WeatherWidget.axaml.cs` | 修改 | 适配新排版 | +| `Views/Components/WeatherClockWidget.axaml` | 修改 | 改进排版,移除硬编码遮罩 | +| `Views/Components/WeatherClockWidget.axaml.cs` | 修改 | 适配新排版 | +| `Views/Components/ExtendedWeatherWidget.axaml` | 修改 | 改进排版,指标标签化,预报芯片化 | +| `Views/Components/ExtendedWeatherWidget.axaml.cs` | 修改 | 适配新排版+标签+芯片 | +| `Views/Components/HourlyWeatherWidget.axaml` | 修改 | 改进排版,预报芯片化 | +| `Views/Components/HourlyWeatherWidget.axaml.cs` | 修改 | 适配新排版+芯片 | +| `Views/Components/MultiDayWeatherWidget.axaml` | 修改 | 改进排版 | +| `Views/Components/MultiDayWeatherWidget.axaml.cs` | 修改 | 适配新排版 | +| `mocks/weather-widget-mock.html` | 新建 | HTML Mock 预览(4种风格×2种天气×2种主题) | + +--- + +## Task 分解 + +### Task 1: 创建 HTML Mock 预览 + +**Files:** +- Create: `mocks/weather-widget-mock.html` + +- [ ] **Step 1: 创建 HTML Mock 文件** + +创建完整的 HTML Mock,包含: +- 4 种风格(Google / Geometric / Breezy / Lemon)× 2 种天气(晴/雨)× 2 种主题(亮/暗) +- 每种风格展示基础天气组件(温度+状况+图标+位置) +- 改进后的排版:超大温度、层次分明、指标标签化 +- 亮色/暗色主题切换按钮 + +- [ ] **Step 2: 在浏览器中打开 Mock 验证效果** + +Run: `start mocks/weather-widget-mock.html` + +--- + +### Task 2: 扩展 MaterialWeatherVisualTheme 支持多风格配色 + +**Files:** +- Modify: `LanMountainDesktop/Views/Components/MaterialWeatherVisualTheme.cs` + +- [ ] **Step 1: 修改 ResolvePalette 方法签名** + +将 `ResolvePalette(MaterialWeatherCondition condition, bool isNight)` 改为 `ResolvePalette(string? styleId, MaterialWeatherCondition condition, bool isNight)`,内部按 styleId 分派到不同配色方案。 + +- [ ] **Step 2: 新增 Google Weather 配色表** + +为 GoogleWeatherV4 风格定义所有天气条件×昼夜的配色(参考上面配色方案设计章节)。 + +- [ ] **Step 3: 新增 Geometric 配色表** + +为 Geometric 风格定义深色系配色。 + +- [ ] **Step 4: 新增 Breezy 配色表** + +为 Breezy 风格定义清新渐变配色。 + +- [ ] **Step 5: 新增 Lemon 配色表** + +为 LemonFlutter 风格定义暖色系配色。 + +- [ ] **Step 6: 更新所有调用点** + +将所有 `ResolvePalette(condition, isNight)` 调用改为 `ResolvePalette(styleId, condition, isNight)`。 + +--- + +### Task 3: 重构 MaterialWeatherSceneControl 支持多风格背景 + +**Files:** +- Modify: `LanMountainDesktop/Views/Components/MaterialWeatherSceneControl.cs` + +- [ ] **Step 1: 扩展 Apply 方法签名** + +将 `Apply(MaterialWeatherCondition condition, MaterialWeatherPalette palette, bool isLive)` 改为 `Apply(string? styleId, MaterialWeatherCondition condition, MaterialWeatherPalette palette, bool isLive)`,存储 styleId。 + +- [ ] **Step 2: 实现 Google Weather 风格渲染** + +纯渐变背景,无几何装饰。背景使用 palette 的 BackgroundTop→BackgroundBottom 渐变。仅保留天气特效(雨滴/雪花/雾线)。 + +- [ ] **Step 3: 实现 Geometric 风格渲染** + +深色渐变 + 大面积半透明几何圆形叠加。在基础渐变上叠加 2-3 个大椭圆(使用 palette 的 PrimaryShape/SecondaryShape/AccentShape),营造深度感。保留天气特效。 + +- [ ] **Step 4: 实现 Breezy 风格渲染** + +清新渐变 + 柔和波浪线条。在基础渐变上绘制 2-3 条正弦波浪线(使用 palette 的 SurfaceTint),营造微风感。保留天气特效。 + +- [ ] **Step 5: 实现 Lemon 风格渲染** + +暖色渐变 + 天气场景装饰。晴天绘制太阳光芒(放射线),多云绘制云朵轮廓,雨天绘制雨丝。保留天气特效。 + +- [ ] **Step 6: 更新所有调用点** + +将所有 `SceneControl.Apply(condition, palette, isLive)` 改为 `SceneControl.Apply(styleId, condition, palette, isLive)`。 + +--- + +### Task 4: 更新 WeatherWidgetBase 传递 styleId + +**Files:** +- Modify: `LanMountainDesktop/Views/Components/WeatherWidgetBase.cs` + +- [ ] **Step 1: 修改 ApplyCurrentScene 方法** + +在 `ApplyCurrentScene()` 中将 `CurrentVisualStyleId` 传递给 `SceneControl.Apply()`。 + +- [ ] **Step 2: 修改 ApplySnapshot 中的 ResolvePalette 调用** + +将 `MaterialWeatherVisualTheme.ResolvePalette(CurrentCondition, isNight)` 改为 `MaterialWeatherVisualTheme.ResolvePalette(CurrentVisualStyleId, CurrentCondition, isNight)`。 + +--- + +### Task 5: 改进各天气 Widget 的 XAML 排版 + +**Files:** +- Modify: `WeatherWidget.axaml` — 移除硬编码遮罩 ``,改用 palette 驱动的半透明遮罩 +- Modify: `WeatherClockWidget.axaml` — 同上 +- Modify: `ExtendedWeatherWidget.axaml` — 同上 + 指标区域改用标签样式 +- Modify: `HourlyWeatherWidget.axaml` — 同上 + 预报区域改用芯片样式 +- Modify: `MultiDayWeatherWidget.axaml` — 同上 + +- [ ] **Step 1: 移除所有硬编码遮罩** + +将 `` / `#42FFFFFF` / `#34FFFFFF` / `#38FFFFFF` / `#3CFFFFFF` 替换为 ``,在 code-behind 中根据 palette 设置遮罩颜色。 + +- [ ] **Step 2: 改进 WeatherWidget 排版** + +增大温度字号(58→64),增加图标与文字间距,调整位置文字透明度。 + +- [ ] **Step 3: 改进 WeatherClockWidget 排版** + +增大时钟字号,增加天气信息与时间间距。 + +- [ ] **Step 4: 改进 ExtendedWeatherWidget 排版** + +指标用半透明圆角标签,小时/每日预报用圆角芯片卡片。 + +- [ ] **Step 5: 改进 HourlyWeatherWidget 排版** + +预报区域用圆角芯片卡片样式。 + +- [ ] **Step 6: 改进 MultiDayWeatherWidget 排版** + +每日预报行增加分隔线和更好的间距。 + +--- + +### Task 6: 更新各天气 Widget 的 code-behind + +**Files:** +- Modify: 所有天气 Widget 的 `.axaml.cs` 文件 + +- [ ] **Step 1: 更新 WeatherWidget.axaml.cs** + +- 设置 OverlayBorder 背景 +- 增大温度字号 +- 适配新排版参数 + +- [ ] **Step 2: 更新 WeatherClockWidget.axaml.cs** + +- 设置 OverlayBorder 背景 +- 适配新排版 + +- [ ] **Step 3: 更新 ExtendedWeatherWidget.axaml.cs** + +- 设置 OverlayBorder 背景 +- 指标标签化(CreateMetric 改为带圆角背景的标签) +- 预报芯片化 + +- [ ] **Step 4: 更新 HourlyWeatherWidget.axaml.cs** + +- 设置 OverlayBorder 背景 +- 预报芯片化(CreateChip 改为带圆角背景的芯片) + +- [ ] **Step 5: 更新 MultiDayWeatherWidget.axaml.cs** + +- 设置 OverlayBorder 背景 +- 适配新排版 + +--- + +### Task 7: 验证与测试 + +- [ ] **Step 1: 运行项目查看效果** + +Run: `dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj` + +- [ ] **Step 2: 运行相关测试** + +Run: `dotnet test LanMountainDesktop.slnx -c Debug` + +- [ ] **Step 3: 检查圆角规范合规** + +确认所有组件 RootBorder 使用 `DesignCornerRadiusComponent`,新增的标签/芯片使用 `DesignCornerRadiusSm`/`DesignCornerRadiusMd`。 + +--- + +## 假设与决策 + +1. **4 套独立风格**: 每种图标风格对应独立的背景配色和装饰风格,切换图标风格时背景也跟着变 +2. **配色表驱动**: 所有颜色定义在 `MaterialWeatherVisualTheme` 中,不硬编码到 SceneControl +3. **保留天气特效**: 雨滴/雪花/雾线/闪电等天气特效在所有风格中保留,但颜色跟随 palette +4. **遮罩动态化**: 半透明遮罩颜色从 palette 中派生,而非硬编码 `#30FFFFFF` +5. **排版渐进改进**: 不做大规模 XAML 重构,而是在现有结构上优化字号/间距/透明度 +6. **数据层不变**: WeatherSnapshot、WeatherIconAssetResolver、WeatherWidgetBase 的数据逻辑不变 +7. **接口兼容**: IDesktopComponentWidget 等接口实现不变 + +## 验证步骤 + +1. HTML Mock 在浏览器中展示 4 种风格效果满意 +2. Avalonia 项目编译通过 +3. 运行项目,切换图标风格时背景配色和装饰风格跟着变化 +4. 亮色/暗色主题切换正常 +5. 5 个天气组件排版层次分明 +6. 指标标签化和预报芯片化正常显示 +7. 测试通过 diff --git a/.trae/specs/air-app-whiteboard/checklist.md b/.trae/specs/air-app-whiteboard/checklist.md new file mode 100644 index 0000000..09e5399 --- /dev/null +++ b/.trae/specs/air-app-whiteboard/checklist.md @@ -0,0 +1,7 @@ +# Checklist + +- [x] Main app builds in Debug. +- [x] AirAppHost builds in Debug. +- [x] Tests project builds in Debug. +- [x] `AirAppLauncherServiceTests` pass. +- [ ] Manual UI verification on a running desktop session. diff --git a/.trae/specs/air-app-whiteboard/spec.md b/.trae/specs/air-app-whiteboard/spec.md new file mode 100644 index 0000000..4d3dd95 --- /dev/null +++ b/.trae/specs/air-app-whiteboard/spec.md @@ -0,0 +1,26 @@ +# Air APP Whiteboard + +## Goal + +Allow the built-in whiteboard desktop components to open a full-screen Air APP that runs in `LanMountainDesktop.AirAppHost` and reuses the same persisted whiteboard note as the source component instance. + +## Scope + +- Add a toolbar surface-mode button to `WhiteboardWidget`. +- In component mode, the button opens the `whiteboard` Air APP through `IAirAppLauncherService`. +- In Air APP mode, the same button saves the current note and closes the Air APP window. +- `DesktopWhiteboard` and `DesktopBlackboardLandscape` share the same mechanism and keep using their component id plus placement id as the note identity. +- `LanMountainDesktop.AirAppHost` may reference the host assembly to reuse built-in UI controls, but the host app must not reference AirAppHost as a normal assembly dependency. + +## Out of Scope + +- Third-party Air APP SDK declarations. +- Whiteboard feature rewrites or alternate whiteboard persistence. +- Taskbar minimization behavior; v1 closes the Air APP window when the user exits from the bottom toolbar. + +## Acceptance + +- Building the main app also builds and copies `LanMountainDesktop.AirAppHost` output. +- Clicking the whiteboard toolbar full-screen button launches a separate AirAppHost process. +- Repeated opens of the same whiteboard component instance activate the existing process instead of spawning duplicates. +- Closing and reopening the Air APP keeps the same whiteboard contents. diff --git a/.trae/specs/air-app-whiteboard/tasks.md b/.trae/specs/air-app-whiteboard/tasks.md new file mode 100644 index 0000000..ac10974 --- /dev/null +++ b/.trae/specs/air-app-whiteboard/tasks.md @@ -0,0 +1,8 @@ +# Tasks + +- [x] Add `whiteboard` launch support to `AirAppLauncherService`. +- [x] Add whiteboard single-instance keys based on component id and placement id. +- [x] Add component/Air APP surface modes to `WhiteboardWidget`. +- [x] Render `WhiteboardWidget` full screen from `LanMountainDesktop.AirAppHost`. +- [x] Keep AirAppHost build/copy output available from the main app build. +- [x] Add launcher argument and instance-key tests. diff --git a/.trae/specs/air-app-window-chrome/checklist.md b/.trae/specs/air-app-window-chrome/checklist.md new file mode 100644 index 0000000..c3f30dd --- /dev/null +++ b/.trae/specs/air-app-window-chrome/checklist.md @@ -0,0 +1,8 @@ +# Checklist + +- [x] Descriptor supports Standard, Borderless, FullScreen, Tool, and BackgroundOnly modes. +- [x] World Clock Air APP uses FluentAvalonia standard title-bar chrome. +- [x] Whiteboard Air APP opens as a fullscreen titlebar-less window. +- [x] Air APP windows do not use fused desktop bottom-most services. +- [x] Air APP windows do not use `Topmost=true` promotion. +- [ ] Manual verification for each chrome mode once non-built-in Air APP declarations are added. diff --git a/.trae/specs/air-app-window-chrome/spec.md b/.trae/specs/air-app-window-chrome/spec.md new file mode 100644 index 0000000..83de3d8 --- /dev/null +++ b/.trae/specs/air-app-window-chrome/spec.md @@ -0,0 +1,22 @@ +# Air APP Window Chrome + +## Goal + +Give Air APPs explicit window chrome modes so title bars, fullscreen windows, borderless windows, tool windows, and future background-only apps are configured by the Air APP host instead of ad hoc component code. + +## Behavior + +- Air APP host resolves an `AirAppWindowDescriptor` from launch options before creating content. +- Supported chrome modes are `Standard`, `Borderless`, `FullScreen`, `Tool`, and `BackgroundOnly`. +- `Standard` uses FluentAvalonia `FAAppWindow` title-bar chrome and normal app-window behavior. +- `Borderless` removes title-bar chrome while keeping a normal app window surface. +- `FullScreen` removes title-bar chrome and enters fullscreen. +- `Tool` keeps FluentAvalonia title-bar chrome but disables resizing and hides the taskbar entry. +- `BackgroundOnly` is reserved for a later background Air APP lifecycle and is not used by built-in v1 apps. +- Built-in `world-clock` uses `Standard`; built-in `whiteboard` uses `FullScreen`. + +## Out of Scope + +- Third-party plugin Air APP declarations. +- Replacing Launcher lifecycle IPC. +- Moving title-bar rendering into desktop components. diff --git a/.trae/specs/air-app-window-chrome/tasks.md b/.trae/specs/air-app-window-chrome/tasks.md new file mode 100644 index 0000000..e10f857 --- /dev/null +++ b/.trae/specs/air-app-window-chrome/tasks.md @@ -0,0 +1,8 @@ +# Tasks + +- [x] Add `AirAppWindowChromeMode` and `AirAppWindowDescriptor`. +- [x] Map built-in `world-clock` to `Standard` chrome. +- [x] Map built-in `whiteboard` to `FullScreen` chrome. +- [x] Apply descriptor settings from `AirAppWindow`. +- [x] Add regression tests for supported modes and built-in mode mapping. +- [x] Replace the hand-rolled Air APP title bar with FluentAvalonia `FAAppWindow` chrome. diff --git a/.trae/specs/clock-air-app-mvp/checklist.md b/.trae/specs/clock-air-app-mvp/checklist.md new file mode 100644 index 0000000..2ffd09d --- /dev/null +++ b/.trae/specs/clock-air-app-mvp/checklist.md @@ -0,0 +1,13 @@ +# Checklist + +- [x] Clicking `DesktopClock` and `DesktopWorldClock` opens the same global Clock Air APP type. +- [x] Repeated `world-clock` open requests use the global `world-clock:clock-suite:global` instance key. +- [x] Whiteboard Air APP keeps its per-component instance key behavior. +- [x] Clock Air APP opens as a normal application window, not a desktop-layer window. +- [x] Clock Air APP settings are independent from desktop clock widget settings. +- [x] Corrupt Clock Air APP settings fall back to defaults. +- [x] World clock time labels support 12-hour, 24-hour, and follow-system formatting. +- [x] Added localization keys are present in all four supported language files. +- [x] Build and automated tests pass. +- [ ] Manual visual verification in all four languages. +- [ ] Manual verification that minimizing keeps stopwatch and timer running while closing stops them. diff --git a/.trae/specs/clock-air-app-mvp/spec.md b/.trae/specs/clock-air-app-mvp/spec.md new file mode 100644 index 0000000..c20634d --- /dev/null +++ b/.trae/specs/clock-air-app-mvp/spec.md @@ -0,0 +1,42 @@ +# Clock Air APP MVP + +## Goal + +Upgrade the built-in `world-clock` Air APP into a focused clock suite while keeping desktop clock widgets as lightweight launch entry points. + +## Scope + +- Keep the existing Air APP id `world-clock` for Launcher lifecycle compatibility. +- Use one global Clock Air APP instance for every clock widget entry point. +- Provide four tabs: World Clock, Stopwatch, Timer, and Settings. +- Store Clock Air APP settings independently from desktop widget settings at `AirApps/Clock/settings.json`. +- Follow the host language setting and provide localized text for `zh-CN`, `en-US`, `ja-JP`, and `ko-KR`. + +## Behavior + +- `world-clock` opens as a standard resizable FluentAvalonia window. +- The default window size is approximately `780x560`, with a minimum of `680x480`. +- World Clock shows local time and a configurable city list. +- Default city list is Beijing, London, Sydney, and New York. +- Users can add, remove, and reorder city entries during the Air APP session; the list persists across restarts. +- Stopwatch supports start, pause, resume, lap, and reset; laps are kept in the current window session, up to 50 entries. +- Timer supports fixed presets, a custom minute duration, start, pause, resume, reset, and a completed state. +- Closing the Clock Air APP stops stopwatch and timer activity. +- Minimizing the window keeps stopwatch and timer activity running. +- Timer completion can activate the Clock Air APP window when the setting is enabled. + +## Settings + +- Time format: follow system, 24-hour, or 12-hour. +- Show seconds. +- Startup tab: last used tab, World Clock, Stopwatch, or Timer. +- Activate window when timer finishes. + +## Out of Scope + +- Desktop clock widget visual redesign. +- Alarms. +- Focus mode. +- System notifications. +- Running stopwatch or timer after the Air APP window is closed. +- Third-party plugin Air APP declarations. diff --git a/.trae/specs/clock-air-app-mvp/tasks.md b/.trae/specs/clock-air-app-mvp/tasks.md new file mode 100644 index 0000000..716f4f7 --- /dev/null +++ b/.trae/specs/clock-air-app-mvp/tasks.md @@ -0,0 +1,15 @@ +# Tasks + +- [x] Add Clock Air APP settings snapshot and JSON store. +- [x] Add shared Clock Air APP time formatting helpers. +- [x] Add stopwatch and timer state models with focused tests. +- [x] Replace the old world-clock view with `ClockAirAppView`. +- [x] Configure `world-clock` as a standard resizable Air APP window. +- [x] Make `world-clock` use a global single-instance key independent of source component id. +- [x] Add world clock city add, remove, and reorder behavior. +- [x] Add stopwatch tab with lap support. +- [x] Add timer tab with presets and custom duration. +- [x] Add independent Clock Air APP settings tab. +- [x] Add `zh-CN`, `en-US`, `ja-JP`, and `ko-KR` localization keys. +- [x] Ensure AirAppHost output includes localization JSON resources. +- [x] Add regression tests for Launcher keying, descriptors, settings, formatting, stopwatch, timer, and localization coverage. diff --git a/.trae/specs/data-settings-page/design.md b/.trae/specs/data-settings-page/design.md new file mode 100644 index 0000000..3714ce3 --- /dev/null +++ b/.trae/specs/data-settings-page/design.md @@ -0,0 +1,104 @@ +# 数据设置页设计文档 + +## 概述 + +在设置窗口中新增「数据」设置页,用于可视化展示和管理阑山桌面产生的各类本地数据。采用 Fluent Design 风格的横向堆叠条形图展示存储分布。 + +## 设计目标 + +1. 让用户直观了解阑山桌面占用的存储空间 +2. 提供各类数据的占比可视化 +3. 支持按类别清理数据 +4. 显示相对于磁盘总容量的占比 + +## 页面结构 + +### 存储概览区域 + +顶部一个卡片,包含: +- **横向堆叠条形图** — 各类数据用不同颜色的分段表示 +- **总占用大小** — 阑山桌面数据总大小(如 "1.2 GB") +- **磁盘占比** — 占总磁盘空间的百分比(如 "占 C 盘 0.5%") +- **图例** — 各颜色对应的数据类型 + +### 数据类型详情列表 + +下方列表展示每类数据: +- 图标 + 名称 +- 占用大小 +- 描述/路径提示 +- 「清理」按钮(如适用) + +### 操作按钮 + +- 「刷新」— 重新扫描数据大小 +- 「一键清理」— 清理所有可清理的数据 + +## 数据类型 + +| 类型 | 颜色 | 可清理 | 路径 | +|------|------|--------|------| +| 日志文件 | 灰色 | 是 | `log/` | +| 白板笔记 | 橙色 | 是(过期) | `Whiteboards/` | +| 插件数据 | 蓝色 | 是 | `Extensions/Plugins/` | +| 插件市场缓存 | 紫色 | 是 | `PluginMarket/` | +| 壁纸文件 | 粉色 | 是 | `Wallpapers/` | +| 设置文件 | 绿色 | 否 | `settings.json` | + +## 技术实现 + +### 新增文件 + +- `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml` — 页面视图 +- `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs` — 页面代码隐藏 +- `LanMountainDesktop/ViewModels/DataSettingsPageViewModel.cs` — 视图模型 +- `LanMountainDesktop/Services/DataStorageService.cs` — 数据扫描服务 + +### 修改文件 + +- `LanMountainDesktop/Views/SettingsWindow.axaml.cs` — 图标映射(MapIcon)添加 Database 图标 + +### 设置页注册 + +```csharp +[SettingsPageInfo( + "data", + "Data", + SettingsPageCategory.General, + IconKey = "Database", + SortOrder = 5, + TitleLocalizationKey = "settings.data.title", + DescriptionLocalizationKey = "settings.data.description")] +``` + +## 视觉设计 + +### 堆叠条形图 + +- 高度:24-32dp +- 圆角:使用 `DesignCornerRadiusSm` +- 分段间距:2dp +- 未占用空间:透明或浅色背景 + +### 颜色方案 + +使用 Material Design 颜色,与主题协调: +- 日志:Gray / BlueGray +- 白板:Orange / Amber +- 插件:Blue / Indigo +- 缓存:Purple / DeepPurple +- 壁纸:Pink +- 设置:Green / Teal + +## 交互行为 + +1. 页面加载时自动扫描数据大小(异步) +2. 显示加载指示器 +3. 清理操作需要确认对话框 +4. 清理完成后自动刷新数据 + +## 安全考虑 + +- 清理前确认用户意图 +- 设置文件不可清理(防止误删配置) +- 清理操作记录日志 diff --git a/.trae/specs/data-settings-page/plan.md b/.trae/specs/data-settings-page/plan.md new file mode 100644 index 0000000..56b616e --- /dev/null +++ b/.trae/specs/data-settings-page/plan.md @@ -0,0 +1,777 @@ +# 数据设置页实现计划 + +> **Goal:** 在设置窗口中新增「数据」设置页,可视化展示阑山桌面各类本地数据的存储占用,支持数据清理。 + +> **Architecture:** 采用 MVVM 模式,新增 DataStorageService 负责异步扫描各类数据大小,DataSettingsPage 使用 Fluent Design 横向堆叠条形图展示存储分布。 + +> **Tech Stack:** Avalonia UI, FluentAvaloniaUI, CommunityToolkit.Mvvm, C# 13 + +--- + +## 文件结构 + +| 文件 | 职责 | +|------|------| +| `LanMountainDesktop/Services/DataStorageService.cs` | 扫描各类数据目录大小,计算磁盘总容量 | +| `LanMountainDesktop/ViewModels/DataSettingsPageViewModel.cs` | 数据设置页视图模型,绑定存储数据和清理命令 | +| `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml` | 数据设置页 XAML 视图(堆叠条形图 + 列表) | +| `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs` | 页面代码隐藏,注册设置页属性 | +| `LanMountainDesktop/Views/SettingsWindow.axaml.cs` | 修改图标映射,添加 Database 图标 | + +--- + +## Task 1: 创建 DataStorageService + +**Files:** +- Create: `LanMountainDesktop/Services/DataStorageService.cs` + +**职责:** 扫描阑山桌面各类数据的存储占用,计算磁盘总容量。 + +- [ ] **Step 1: 创建 DataStorageService** + +```csharp +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace LanMountainDesktop.Services; + +public sealed record StorageCategoryInfo( + string Id, + string Name, + string Description, + string DirectoryPath, + bool IsCleanable, + string ColorHex); + +public sealed record StorageScanResult( + StorageCategoryInfo Category, + long SizeBytes, + double PercentageOfTotal); + +public sealed class DataStorageService +{ + private static readonly IReadOnlyList Categories = new List + { + new("logs", "日志文件", "应用运行日志", "", true, "#9E9E9E"), + new("whiteboards", "白板笔记", "桌面白板笔记数据", "", true, "#FF9800"), + new("plugins", "插件数据", "已安装插件文件", "", true, "#2196F3"), + new("market", "插件市场缓存", "插件市场元数据缓存", "", true, "#9C27B0"), + new("wallpapers", "壁纸文件", "下载的壁纸资源", "", true, "#E91E63"), + new("settings", "设置文件", "应用配置数据", "", false, "#4CAF50") + }; + + public IReadOnlyList GetCategories() => Categories; + + public async Task> ScanAsync(CancellationToken cancellationToken = default) + { + var results = new List(); + var dataRoot = AppDataPathProvider.GetDataRoot(); + var logDirectory = AppLogger.LogDirectory; + + long totalSize = 0; + var categorySizes = new Dictionary(); + + foreach (var category in Categories) + { + cancellationToken.ThrowIfCancellationRequested(); + + string path = category.Id switch + { + "logs" => logDirectory, + "settings" => dataRoot, + _ => Path.Combine(dataRoot, category.DirectoryPath) + }; + + long size = 0; + if (category.Id == "settings") + { + size = await GetSettingsSizeAsync(dataRoot, cancellationToken); + } + else if (Directory.Exists(path)) + { + size = await GetDirectorySizeAsync(path, cancellationToken); + } + + categorySizes[category.Id] = size; + totalSize += size; + } + + foreach (var category in Categories) + { + var size = categorySizes.GetValueOrDefault(category.Id, 0); + var percentage = totalSize > 0 ? (double)size / totalSize * 100 : 0; + results.Add(new StorageScanResult(category, size, percentage)); + } + + return results; + } + + public async Task GetTotalDiskSpaceAsync(CancellationToken cancellationToken = default) + { + return await Task.Run(() => + { + var dataRoot = AppDataPathProvider.GetDataRoot(); + var driveInfo = new DriveInfo(Path.GetPathRoot(dataRoot) ?? dataRoot); + return driveInfo.TotalSize; + }, cancellationToken); + } + + public async Task GetAvailableDiskSpaceAsync(CancellationToken cancellationToken = default) + { + return await Task.Run(() => + { + var dataRoot = AppDataPathProvider.GetDataRoot(); + var driveInfo = new DriveInfo(Path.GetPathRoot(dataRoot) ?? dataRoot); + return driveInfo.AvailableFreeSpace; + }, cancellationToken); + } + + public async Task CleanCategoryAsync(string categoryId, CancellationToken cancellationToken = default) + { + var category = Categories.FirstOrDefault(c => + string.Equals(c.Id, categoryId, StringComparison.OrdinalIgnoreCase)); + + if (category is null || !category.IsCleanable) + { + return false; + } + + var dataRoot = AppDataPathProvider.GetDataRoot(); + string path = categoryId switch + { + "logs" => AppLogger.LogDirectory, + _ => Path.Combine(dataRoot, category.DirectoryPath) + }; + + if (!Directory.Exists(path)) + { + return false; + } + + return await Task.Run(() => + { + try + { + if (categoryId == "logs") + { + foreach (var file in Directory.GetFiles(path, "*.log")) + { + cancellationToken.ThrowIfCancellationRequested(); + TryDeleteFile(file); + } + } + else + { + foreach (var file in Directory.GetFiles(path, "*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + TryDeleteFile(file); + } + + foreach (var dir in Directory.GetDirectories(path, "*", SearchOption.AllDirectories) + .OrderByDescending(d => d.Length)) + { + cancellationToken.ThrowIfCancellationRequested(); + TryDeleteDirectory(dir); + } + } + + AppLogger.Info("DataStorage", $"Cleaned category '{categoryId}' at '{path}'."); + return true; + } + catch (Exception ex) + { + AppLogger.Warn("DataStorage", $"Failed to clean category '{categoryId}'.", ex); + return false; + } + }, cancellationToken); + } + + private static async Task GetDirectorySizeAsync(string path, CancellationToken cancellationToken) + { + return await Task.Run(() => + { + long size = 0; + try + { + foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var info = new FileInfo(file); + if (info.Exists) + { + size += info.Length; + } + } + catch + { + // Ignore files we can't access + } + } + } + catch + { + // Ignore directories we can't access + } + + return size; + }, cancellationToken); + } + + private static async Task GetSettingsSizeAsync(string dataRoot, CancellationToken cancellationToken) + { + return await Task.Run(() => + { + long size = 0; + var settingFiles = new[] { "settings.json", "plugin-settings.json", "launcher-settings.json" }; + foreach (var file in settingFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + var path = Path.Combine(dataRoot, file); + if (File.Exists(path)) + { + try + { + size += new FileInfo(path).Length; + } + catch + { + // Ignore + } + } + } + + return size; + }, cancellationToken); + } + + private static void TryDeleteFile(string path) + { + try + { + File.SetAttributes(path, FileAttributes.Normal); + File.Delete(path); + } + catch + { + // Ignore deletion failures + } + } + + private static void TryDeleteDirectory(string path) + { + try + { + Directory.Delete(path, false); + } + catch + { + // Ignore deletion failures + } + } + + public static string FormatBytes(long bytes) + { + const long KB = 1024; + const long MB = KB * 1024; + const long GB = MB * 1024; + const long TB = GB * 1024; + + return bytes switch + { + >= TB => $"{bytes / (double)TB:F2} TB", + >= GB => $"{bytes / (double)GB:F2} GB", + >= MB => $"{bytes / (double)MB:F2} MB", + >= KB => $"{bytes / (double)KB:F2} KB", + _ => $"{bytes} B" + }; + } +} +``` + +--- + +## Task 2: 创建 DataSettingsPageViewModel + +**Files:** +- Create: `LanMountainDesktop/ViewModels/DataSettingsPageViewModel.cs` + +- [ ] **Step 1: 创建 ViewModel** + +```csharp +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.ViewModels; + +public sealed partial class DataStorageItemViewModel : ObservableObject +{ + public string Id { get; } + public string Name { get; } + public string Description { get; } + public string ColorHex { get; } + public bool IsCleanable { get; } + + [ObservableProperty] + private string _sizeText = "--"; + + [ObservableProperty] + private double _percentage; + + [ObservableProperty] + private bool _isCleaning; + + public DataStorageItemViewModel(StorageCategoryInfo category) + { + Id = category.Id; + Name = category.Name; + Description = category.Description; + ColorHex = category.ColorHex; + IsCleanable = category.IsCleanable; + } + + public void UpdateSize(long sizeBytes, double percentage) + { + SizeText = DataStorageService.FormatBytes(sizeBytes); + Percentage = percentage; + } +} + +public sealed partial class DataSettingsPageViewModel : ViewModelBase +{ + private readonly DataStorageService _storageService = new(); + private CancellationTokenSource? _scanCts; + + [ObservableProperty] + private string _pageTitle = "数据与存储"; + + [ObservableProperty] + private string _totalSizeText = "--"; + + [ObservableProperty] + private string _diskUsageText = "--"; + + [ObservableProperty] + private double _diskUsagePercentage; + + [ObservableProperty] + private bool _isScanning; + + [ObservableProperty] + private bool _hasData; + + public ObservableCollection Items { get; } = new(); + + public DataSettingsPageViewModel() + { + var categories = _storageService.GetCategories(); + foreach (var category in categories) + { + Items.Add(new DataStorageItemViewModel(category)); + } + + _ = ScanAsync(); + } + + [RelayCommand] + private async Task ScanAsync() + { + _scanCts?.Cancel(); + _scanCts = new CancellationTokenSource(); + var token = _scanCts.Token; + + IsScanning = true; + try + { + var results = await _storageService.ScanAsync(token); + var totalSize = results.Sum(r => r.SizeBytes); + var totalDisk = await _storageService.GetTotalDiskSpaceAsync(token); + + await Dispatcher.UIThread.InvokeAsync(() => + { + TotalSizeText = DataStorageService.FormatBytes(totalSize); + DiskUsagePercentage = totalDisk > 0 ? (double)totalSize / totalDisk * 100 : 0; + DiskUsageText = $"占总磁盘 {DiskUsagePercentage:F1}%"; + HasData = totalSize > 0; + + foreach (var result in results) + { + var item = Items.FirstOrDefault(i => + string.Equals(i.Id, result.Category.Id, StringComparison.OrdinalIgnoreCase)); + item?.UpdateSize(result.SizeBytes, result.PercentageOfTotal); + } + }); + } + catch (OperationCanceledException) + { + // Ignore cancellation + } + catch (Exception ex) + { + AppLogger.Warn("DataSettings", "Failed to scan storage.", ex); + } + finally + { + IsScanning = false; + } + } + + [RelayCommand] + private async Task CleanAsync(string categoryId) + { + var item = Items.FirstOrDefault(i => + string.Equals(i.Id, categoryId, StringComparison.OrdinalIgnoreCase)); + if (item is null || !item.IsCleanable) + { + return; + } + + item.IsCleaning = true; + try + { + await _storageService.CleanCategoryAsync(categoryId); + await ScanAsync(); + } + catch (Exception ex) + { + AppLogger.Warn("DataSettings", $"Failed to clean category '{categoryId}'.", ex); + } + finally + { + item.IsCleaning = false; + } + } + + [RelayCommand] + private async Task CleanAllAsync() + { + foreach (var item in Items.Where(i => i.IsCleanable)) + { + item.IsCleaning = true; + } + + try + { + foreach (var item in Items.Where(i => i.IsCleanable)) + { + await _storageService.CleanCategoryAsync(item.Id); + } + + await ScanAsync(); + } + catch (Exception ex) + { + AppLogger.Warn("DataSettings", "Failed to clean all categories.", ex); + } + finally + { + foreach (var item in Items) + { + item.IsCleaning = false; + } + } + } +} +``` + +--- + +## Task 3: 创建 DataSettingsPage.axaml + +**Files:** +- Create: `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml` + +- [ ] **Step 1: 创建 XAML 视图** + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +--- + +## Task 4: 创建 DataSettingsPage.axaml.cs + +**Files:** +- Create: `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs` + +- [ ] **Step 1: 创建代码隐藏** + +```csharp +using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.ViewModels; + +namespace LanMountainDesktop.Views.SettingsPages; + +[SettingsPageInfo( + "data", + "Data", + SettingsPageCategory.General, + IconKey = "Database", + SortOrder = 5, + TitleLocalizationKey = "settings.data.title", + DescriptionLocalizationKey = "settings.data.description")] +public partial class DataSettingsPage : SettingsPageBase +{ + public DataSettingsPage() + : this(new DataSettingsPageViewModel()) + { + } + + public DataSettingsPage(DataSettingsPageViewModel viewModel) + { + ViewModel = viewModel; + DataContext = ViewModel; + InitializeComponent(); + } + + public DataSettingsPageViewModel ViewModel { get; } +} +``` + +--- + +## Task 5: 修改 SettingsWindow.axaml.cs 添加图标映射 + +**Files:** +- Modify: `LanMountainDesktop/Views/SettingsWindow.axaml.cs` + +- [ ] **Step 1: 在 MapIcon 方法中添加 Database 图标映射** + +在 `MapIcon` 方法的 switch 表达式中添加: + +```csharp +"Database" => Symbol.Database, +``` + +--- + +## Task 6: 添加颜色转换器(如需要) + +**Files:** +- Modify: `LanMountainDesktop/Theme/` 或 `LanMountainDesktop/Controls/` 中的资源字典 + +如果项目中没有 HexToBrushConverter,需要创建一个简单的值转换器: + +```csharp +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace LanMountainDesktop.Converters; + +public class HexToBrushConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string hex && !string.IsNullOrWhiteSpace(hex)) + { + try + { + return new SolidColorBrush(Color.Parse(hex)); + } + catch + { + // Ignore parse errors + } + } + + return new SolidColorBrush(Colors.Gray); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} +``` + +--- + +## 测试验证 + +1. 构建项目:`dotnet build LanMountainDesktop.slnx -c Debug` +2. 运行应用:`dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj` +3. 打开设置窗口,确认「数据」选项卡出现在左侧导航中 +4. 点击「数据」选项卡,确认: + - 堆叠条形图显示各类数据占比 + - 总大小和磁盘占比显示正确 + - 数据详情列表显示每类数据大小 + - 刷新按钮可以重新扫描 + - 清理按钮可以清理对应数据 diff --git a/.trae/specs/dock-back-to-windows-button-display/checklist.md b/.trae/specs/dock-back-to-windows-button-display/checklist.md new file mode 100644 index 0000000..ebf0896 --- /dev/null +++ b/.trae/specs/dock-back-to-windows-button-display/checklist.md @@ -0,0 +1,13 @@ +# Checklist + +- [ ] `AppSettingsSnapshot.BackToWindowsButtonDisplayMode` exists and defaults to `IconAndText`. +- [ ] `AppSettingsSnapshot` contains icon source, Fluent icon name, and text icon settings with safe defaults. +- [ ] General > Basic Settings includes one folded back-to-platform button settings expander. +- [ ] The expander includes the display-mode dropdown. +- [ ] The expander includes nested icon source, Fluent icon popup picker, and text icon input controls. +- [ ] The Dock button left icon slot renders either a Fluent icon or custom text. +- [ ] `IconAndText`, `IconOnly`, and `TextOnly` modes update the Dock button live. +- [ ] Icon source, Fluent icon name, and text icon updates refresh the Dock button live. +- [ ] The selected mode is preserved when MainWindow saves app settings. +- [ ] Localization keys exist for zh-CN, en-US, ja-JP, and ko-KR. +- [ ] `dotnet build LanMountainDesktop.slnx -c Debug` succeeds. diff --git a/.trae/specs/dock-back-to-windows-button-display/spec.md b/.trae/specs/dock-back-to-windows-button-display/spec.md new file mode 100644 index 0000000..c0d108b --- /dev/null +++ b/.trae/specs/dock-back-to-windows-button-display/spec.md @@ -0,0 +1,29 @@ +# Dock Back To Windows Button Display + +## Summary + +The Dock "Back to platform" action should expose a configurable left icon slot while keeping the localized platform text fixed. + +## Requirements + +- The default display mode is `IconAndText` so existing users keep a familiar Dock layout after upgrade. +- The localized platform text remains controlled by the app and is not user-editable. +- General > Basic Settings exposes one Fluent Avalonia `FASettingsExpander` for the back-to-platform button, with icon-related controls folded into nested `FASettingsExpanderItem` rows. +- The main row exposes a dropdown with `IconAndText`, `IconOnly`, and `TextOnly` options. +- A nested icon source row selects Fluent icon or text icon. +- Fluent icon mode uses a popup picker-style flyout with search and a grid of the full FluentIcons `Icon` enum. +- Text icon mode lets the user enter short text for the left icon slot. +- Changing the dropdown persists to `AppSettingsSnapshot.BackToWindowsButtonDisplayMode` and updates the Dock button without restarting. +- Changing the icon source, Fluent icon, or text icon persists to app settings and updates the Dock button without restarting. +- `IconOnly` keeps the existing tooltip text so the button remains understandable. +- `PinnedTaskbarActions` continues to control whether the action is visible; it does not replace the display mode setting. + +## Acceptance Scenarios + +- With default settings, the Dock button shows a small circle icon and the localized platform text. +- Selecting icon only hides the platform text and keeps the configured left icon visible. +- Selecting text only hides the left icon slot and keeps the localized platform text visible. +- Choosing a Fluent icon changes the left icon slot. +- Entering a short text icon changes the left icon slot. +- Restarting the app restores the selected display mode. +- Clicking the button still runs the existing minimize/back-to-platform behavior. diff --git a/.trae/specs/fused-desktop-category-icon-unification/checklist.md b/.trae/specs/fused-desktop-category-icon-unification/checklist.md new file mode 100644 index 0000000..de56722 --- /dev/null +++ b/.trae/specs/fused-desktop-category-icon-unification/checklist.md @@ -0,0 +1,14 @@ +- [x] ComponentCategoryIconResolver 基于 IconKey 正确解析分类图标 +- [x] IconKey 为 "Clock" 时解析为 Icon.Clock +- [x] IconKey 为 "WeatherSunny" 时解析为 Icon.WeatherSunny +- [x] IconKey 为 "News" 时解析为 Icon.News +- [x] IconKey 为 "Edit" 时解析为 Icon.Edit +- [x] IconKey 为无效值时回退到 Icon.Apps +- [x] 分类 ID 为 "all" 时返回 Icon.Apps +- [x] ComponentLibraryCategoryViewModel.Icon 类型为 FluentIcons.Common.Icon +- [x] FusedDesktopComponentLibraryControl.axaml.cs 不再包含硬编码 ResolveCategoryIcon 方法 +- [x] ComponentLibraryWindow.axaml.cs 不再包含硬编码 ResolveCategoryIcon 方法 +- [x] MainWindow.ComponentSystem.cs 不再包含硬编码 ResolveComponentLibraryCategoryIcon 方法 +- [x] 三处组件库入口对同一分类显示相同图标 +- [x] dotnet build 无编译错误 +- [x] dotnet test 全部通过 diff --git a/.trae/specs/fused-desktop-category-icon-unification/spec.md b/.trae/specs/fused-desktop-category-icon-unification/spec.md new file mode 100644 index 0000000..7cca3d8 --- /dev/null +++ b/.trae/specs/fused-desktop-category-icon-unification/spec.md @@ -0,0 +1,73 @@ +# 融合桌面组件库分类图标统一规格 + +## Why + +融合桌面组件库窗口(FusedDesktopComponentLibraryControl)的分类图标使用了手动硬编码的 `ResolveCategoryIcon` 方法映射分类 ID 到 `Symbol` 枚举,与阑山桌面主窗口(MainWindow)中的映射存在不一致(例如 `Info` 分类在主窗口映射到 `Symbol.Apps`,在融合桌面映射到 `Symbol.Info`)。同时,`DesktopComponentDefinition.IconKey` 字段已经存储了正确的 FluentIcon 枚举名称字符串,但未被利用。需要统一三处图标映射逻辑,确保所有组件库入口的分类图标一致且正确。 + +## What Changes + +- **统一分类图标映射**:将三处分散的 `ResolveCategoryIcon`/`ResolveComponentLibraryCategoryIcon` 方法合并为共享的统一映射 +- **使用 `IconKey` 驱动图标**:分类图标应基于该分类下组件的 `IconKey` 字段推导,而非硬编码的分类 ID 映射 +- **使用 `FluentIcons.Common.Icon` 枚举**:`fi:FluentIcon` 控件使用 `Icon` 枚举(非 `Symbol` 枚举),分类图标应使用 `Icon` 枚举以与 `fi:FluentIcon` 兼容 +- **修改 ViewModel**:`ComponentLibraryCategoryViewModel.Icon` 属性类型从 `Symbol` 改为 `Icon` + +## Impact + +- 受影响文件: + - `LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs`(Icon 属性类型从 Symbol 改为 Icon) + - `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml`(绑定路径不变,但 Icon 类型变化) + - `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs`(移除硬编码映射,使用统一方法) + - `LanMountainDesktop/Views/ComponentLibraryWindow.axaml.cs`(移除硬编码映射,使用统一方法) + - `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs`(移除硬编码映射,使用统一方法) + - 新增共享映射工具类(或在现有服务中添加) + +## ADDED Requirements + +### Requirement: 统一分类图标映射 + +系统 SHALL 提供一个共享的分类图标映射方法,所有组件库入口(阑山桌面主窗口、融合桌面组件库、独立组件库窗口)均使用此方法。 + +#### Scenario: 图标映射来源 +- **GIVEN** 一个组件分类 ID +- **WHEN** 需要获取该分类的图标 +- **THEN** 系统应基于该分类下组件的 `IconKey` 字段推导分类图标 +- **AND** 推导规则为:取该分类下第一个组件的 `IconKey`,解析为 `FluentIcons.Common.Icon` 枚举值 +- **AND** 若 `IconKey` 无法解析为有效的 `Icon` 枚举值,则回退到 `Icon.Apps` + +#### Scenario: 特殊分类处理 +- **GIVEN** 分类 ID 为 "all" +- **WHEN** 需要获取该分类的图标 +- **THEN** 系统应返回 `Icon.Apps` + +#### Scenario: 三处映射一致性 +- **GIVEN** 任意一个组件分类 +- **WHEN** 在阑山桌面主窗口、融合桌面组件库、独立组件库窗口中显示该分类 +- **THEN** 三处应显示完全相同的图标 + +### Requirement: ViewModel 使用 Icon 枚举 + +`ComponentLibraryCategoryViewModel.Icon` 属性 SHALL 使用 `FluentIcons.Common.Icon` 枚举类型(而非 `FluentIcons.Common.Symbol`),以与 `fi:FluentIcon` 控件的 `Icon` 属性兼容。 + +#### Scenario: XAML 绑定兼容 +- **GIVEN** `ComponentLibraryCategoryViewModel.Icon` 属性类型为 `Icon` +- **WHEN** 在 XAML 中通过 `{Binding Icon}` 绑定到 `fi:FluentIcon` 控件 +- **THEN** 图标应正确渲染,无需额外转换 + +## MODIFIED Requirements + +### Requirement: 分类图标解析 + +原实现使用硬编码的 `if/switch` 语句将分类 ID 映射到 `Symbol` 枚举,新实现改为: + +- 使用 `DesktopComponentDefinition.IconKey` 字段作为图标来源 +- 通过 `Enum.TryParse(iconKey, ignoreCase: true, out var icon)` 解析 +- 解析失败时回退到 `Icon.Apps` +- 移除所有三处硬编码映射方法 + +### Requirement: ComponentLibraryCategoryViewModel.Icon 类型 + +原类型为 `Symbol`,修改为 `Icon`,与 `fi:FluentIcon` 控件的 `Icon` 依赖属性类型一致。 + +## REMOVED Requirements + +无移除的需求。 diff --git a/.trae/specs/fused-desktop-category-icon-unification/tasks.md b/.trae/specs/fused-desktop-category-icon-unification/tasks.md new file mode 100644 index 0000000..272f0b2 --- /dev/null +++ b/.trae/specs/fused-desktop-category-icon-unification/tasks.md @@ -0,0 +1,38 @@ +# Tasks + +- [x] Task 1: 创建共享分类图标映射工具 + - [x] SubTask 1.1: 在 `LanMountainDesktop.ComponentSystem` 命名空间下创建 `ComponentCategoryIconResolver` 静态类 + - [x] SubTask 1.2: 实现 `ResolveCategoryIcon(string categoryId, IEnumerable categoryComponents)` 方法,基于 IconKey 解析为 `FluentIcons.Common.Icon` + - [x] SubTask 1.3: 添加单元测试验证图标解析逻辑(TDD:先写失败测试,再实现) + +- [x] Task 2: 修改 ViewModel 的 Icon 属性类型 + - [x] SubTask 2.1: 将 `ComponentLibraryCategoryViewModel.Icon` 属性类型从 `Symbol` 改为 `Icon` + - [x] SubTask 2.2: 更新构造函数参数类型 + +- [x] Task 3: 更新 FusedDesktopComponentLibraryControl.axaml.cs + - [x] SubTask 3.1: 移除 `ResolveCategoryIcon` 硬编码方法 + - [x] SubTask 3.2: 在 `LoadCategories` 中使用 `ComponentCategoryIconResolver.ResolveCategoryIcon` + - [x] SubTask 3.3: 更新 "all" 分类图标从 `Symbol.Apps` 改为 `Icon.Apps` + +- [x] Task 4: 更新 ComponentLibraryWindow.axaml.cs + - [x] SubTask 4.1: 移除 `ResolveCategoryIcon` 硬编码方法 + - [x] SubTask 4.2: 使用 `ComponentCategoryIconResolver.ResolveCategoryIcon` + +- [x] Task 5: 更新 MainWindow.ComponentSystem.cs + - [x] SubTask 5.1: 移除 `ResolveComponentLibraryCategoryIcon` 硬编码方法 + - [x] SubTask 5.2: 使用 `ComponentCategoryIconResolver.ResolveCategoryIcon` + - [x] SubTask 5.3: 更新 `ComponentLibraryCategory` 记录的 `Icon` 字段类型从 `Symbol` 改为 `Icon` + - [x] SubTask 5.4: 更新 `GetComponentLibraryCategories` 方法中的图标解析调用 + +- [x] Task 6: 更新 XAML 绑定 + - [x] SubTask 6.1: 验证 `FusedDesktopComponentLibraryControl.axaml` 中 `fi:FluentIcon Icon="{Binding Icon}"` 绑定在新类型下正常工作 + +- [x] Task 7: 构建验证 + - [x] SubTask 7.1: 运行 `dotnet build` 确保无编译错误 + - [x] SubTask 7.2: 运行 `dotnet test` 确保所有测试通过 + +# Task Dependencies +- Task 2 依赖于 Task 1(共享映射工具) +- Task 3、4、5 依赖于 Task 1 和 Task 2 +- Task 6 依赖于 Task 2(类型变更后验证绑定) +- Task 7 依赖于所有前置任务 diff --git a/.trae/specs/launcher-managed-air-app-lifecycle/checklist.md b/.trae/specs/launcher-managed-air-app-lifecycle/checklist.md new file mode 100644 index 0000000..58242db --- /dev/null +++ b/.trae/specs/launcher-managed-air-app-lifecycle/checklist.md @@ -0,0 +1,10 @@ +# Checklist + +- [x] `LanMountainDesktop.Shared.IPC` builds in Debug. +- [x] `LanMountainDesktop.Launcher` builds in Debug. +- [x] `LanMountainDesktop` builds in Debug. +- [x] `LanMountainDesktop.AirAppHost` builds in Debug. +- [x] `LanMountainDesktop.Tests` builds in Debug. +- [x] Air APP launcher and lifecycle unit tests pass. +- [x] Direct-host fallback starts Launcher in `air-app-broker` mode instead of debug/normal launch mode. +- [ ] Manual process-lifetime verification with the running desktop. diff --git a/.trae/specs/launcher-managed-air-app-lifecycle/spec.md b/.trae/specs/launcher-managed-air-app-lifecycle/spec.md new file mode 100644 index 0000000..a7cf1bf --- /dev/null +++ b/.trae/specs/launcher-managed-air-app-lifecycle/spec.md @@ -0,0 +1,22 @@ +# Launcher Managed Air APP Lifecycle + +## Goal + +Make Launcher the authoritative lifecycle manager for built-in Air APP processes. The desktop host requests Air APP operations through IPC, while Launcher creates, activates, tracks, and cleans up Air APP host processes. + +## Behavior + +- Launcher exposes `IAirAppLifecycleService` on the dedicated `LanMountainDesktop.Launcher.AirApp.v1` pipe. +- Desktop host calls Launcher IPC for `world-clock` and `whiteboard`; it does not directly start `LanMountainDesktop.AirAppHost`. +- If the dedicated pipe is unavailable, the desktop host starts Launcher with the hidden `air-app-broker --requester-pid ` command and retries the Air APP request. +- `air-app-broker` starts only the Air APP lifecycle IPC broker. It bypasses OOBE, Splash, debug preview windows, and normal desktop launch orchestration. +- Launcher keeps one Air APP process per `{appId}:{sourceComponentId}:{sourcePlacementId}` key. +- AirAppHost receives Launcher pipe and instance key at startup, registers after the window opens, and unregisters on close. +- Launcher remains alive while the main desktop process or any Air APP process is alive. +- Broker mode remains alive while the requester desktop process or any Air APP process is alive; after both are gone, it exits. + +## Out of Scope + +- Third-party plugin-declared Air APP metadata. +- Cross-machine IPC. +- Persisting the Air APP instance table across OS reboot. diff --git a/.trae/specs/launcher-managed-air-app-lifecycle/tasks.md b/.trae/specs/launcher-managed-air-app-lifecycle/tasks.md new file mode 100644 index 0000000..d9461b7 --- /dev/null +++ b/.trae/specs/launcher-managed-air-app-lifecycle/tasks.md @@ -0,0 +1,11 @@ +# Tasks + +- [x] Add shared Air APP lifecycle IPC contracts. +- [x] Add Launcher Air APP lifecycle service and dedicated IPC host. +- [x] Make Launcher remain alive while desktop or Air APP processes exist. +- [x] Route desktop Air APP launch requests through Launcher IPC. +- [x] Add hidden `air-app-broker` Launcher command for direct-host development fallback. +- [x] Make desktop fallback start `air-app-broker --requester-pid ` instead of normal `launch`. +- [x] Add broker lifetime and command recognition tests. +- [x] Add AirAppHost registration and unregister best-effort calls. +- [x] Add lifecycle service and request-building tests. diff --git a/.trae/specs/launcher-oobe-elevation-hardening/checklist.md b/.trae/specs/launcher-oobe-elevation-hardening/checklist.md index 1afaff9..b9003ac 100644 --- a/.trae/specs/launcher-oobe-elevation-hardening/checklist.md +++ b/.trae/specs/launcher-oobe-elevation-hardening/checklist.md @@ -6,3 +6,4 @@ - [ ] `apply-update` and `plugin-install` do not auto-enter OOBE. - [ ] Default plugin install does not request UAC. - [ ] Logs include OOBE status, suppression reason, and launch source. +- [ ] Startup presentation step inside `OobeWindow` (after data location) writes host `settings.json` and syncs Windows Run when autostart is chosen (Launcher executable). diff --git a/.trae/specs/launcher-shell-hardening/spec.md b/.trae/specs/launcher-shell-hardening/spec.md index 2e724e6..d73c365 100644 --- a/.trae/specs/launcher-shell-hardening/spec.md +++ b/.trae/specs/launcher-shell-hardening/spec.md @@ -65,3 +65,19 @@ - 托盘失败时应用仍保持可恢复。 - Launcher 与应用设置页显示相同版本。 - 100% / 150% / 200% / 250% 缩放下,Launcher OOBE、主窗口入场、通知位置与动画正常。 + +### 5. Launcher IPC and error surface follow-up + +- The legacy `LanMountainDesktop_Launcher` named-pipe startup progress channel is retired. Public IPC notifications and host exit codes are the only startup state sources. +- Normal Launcher launches must probe public IPC for an existing Host before starting a new Host process. Host no longer owns multi-instance policy, activation prompts, or the old single-instance pipe. +- `SecondaryActivationSucceeded` is a success terminal state. `SecondaryActivationFailed` and `RestartLockNotAcquired` may surface as failures only after public IPC recovery has failed. +- Launcher startup errors must use FluentAvalonia resources, Fluent icons, an InfoBar recovery hint, and copyable diagnostics instead of the old hard-coded dark panel. + +### 6. Multi-instance behavior setting + +- App settings include `MultiInstanceLaunchBehavior` with default `NotifyAndOpenDesktop`. +- General settings exposes the behavior under Basic Settings with four choices: restart app, open desktop silently, prompt only, and notify plus open desktop. +- Launcher reads the Host `settings.json` before a normal launch and applies the selected behavior when public IPC reports an existing Host. +- `PromptOnly` shows a Fluent Launcher prompt and does not open the desktop automatically. +- `NotifyAndOpenDesktop` activates the existing Host and shows the already-running notice from Launcher. +- `RestartApp` requests restart through public IPC and must not create a second Host if the restart request fails. diff --git a/.trae/specs/launcher-shell-hardening/tasks.md b/.trae/specs/launcher-shell-hardening/tasks.md index 9644627..b139320 100644 --- a/.trae/specs/launcher-shell-hardening/tasks.md +++ b/.trae/specs/launcher-shell-hardening/tasks.md @@ -12,3 +12,10 @@ - [x] 修复主窗口入场、通知定位和 Launcher OOBE 的高分屏动画/定位问题。 - [x] 补充规格与版本同步说明文档。 - [ ] 追加针对托盘恢复和启动判定的自动化回归测试。 + +- [x] Remove the legacy `LanMountainDesktop_Launcher` startup progress pipe; launcher progress now uses public IPC plus host exit-code classification only. +- [x] Move normal multi-open probing into Launcher before host launch and remove Host-side single-instance prompt/listener code. +- [x] Refresh the Launcher error window with Fluent resources, InfoBar, Fluent icons, command bar actions, and copyable diagnostic details. +- [x] Add app-level `MultiInstanceLaunchBehavior` setting and expose it in General > Basic Settings. +- [x] Make Launcher apply restart/open silently/prompt only/notify and open behavior before starting a new Host. +- [x] Add a Fluent Launcher multi-instance prompt; Host public IPC stays limited to activation/status/restart/exit actions. diff --git a/.trae/specs/launcher-shell-hardening/tray-menu-shutdown-addendum.md b/.trae/specs/launcher-shell-hardening/tray-menu-shutdown-addendum.md index 3067a0f..eb31ed1 100644 --- a/.trae/specs/launcher-shell-hardening/tray-menu-shutdown-addendum.md +++ b/.trae/specs/launcher-shell-hardening/tray-menu-shutdown-addendum.md @@ -4,14 +4,14 @@ - Tray menu `Exit App` must commit an irreversible host shutdown request. - Once shutdown is committed, tray menu actions must not reopen the desktop, settings window, or component library. -- Shutdown cleanup must release Public IPC, plugin runtime, tray icon, fused desktop edit UI, telemetry resources, and the single-instance lock before the forced-exit deadline. +- Shutdown cleanup must release Public IPC, plugin runtime, tray icon, fused desktop edit UI, and telemetry resources before the forced-exit deadline. - Forced process termination must be scheduled when the shutdown request is accepted, not only after Avalonia lifetime exit. - Restart must preserve `RestartRequested` intent and must not route through an exit path that overwrites it. - Fused desktop component library menu activation must reuse the existing library window and must exit edit mode if opening fails. ## Acceptance -- Selecting `Exit App` from the tray leaves no background host process and allows a later Launcher start to acquire the single-instance lock. +- Selecting `Exit App` from the tray leaves no background host process and allows a later Launcher start to perform multi-instance detection through public IPC. - Selecting `Restart App` starts the Launcher or upgrade helper once, then shuts down the old host as a restart. - Repeated tray clicks during shutdown are ignored and logged. - Repeated component-library clicks focus the existing window instead of opening duplicates. diff --git a/.trae/specs/main-window-desktop-layer/design.md b/.trae/specs/main-window-desktop-layer/design.md new file mode 100644 index 0000000..1e8f321 --- /dev/null +++ b/.trae/specs/main-window-desktop-layer/design.md @@ -0,0 +1,42 @@ +# Main Window Desktop Layer Design + +## Window Roles + +Lan Mountain Desktop now has three separate window-layer roles: + +- `MainDesktopWindow`: the normal desktop host window. With `EnableMainWindowDesktopLayer`, this window is moved to the desktop layer so it does not cover ordinary apps. +- `FusedDesktopSurface`: fused desktop component windows such as `DesktopWidgetWindow` and `TransparentOverlayWindow`. These continue to use `IWindowBottomMostService` and their existing click-through region service. +- `AirApp`: independent Air APP windows. These are ordinary app windows and do not use desktop-layer services or global `Topmost` promotion. + +## Service Boundary + +`IMainWindowDesktopLayerService` is dedicated to the main window only. It does not reuse fused desktop passthrough services because the main window must stay interactive. + +Windows behavior: + +- Save original parent, style, and extended style before enabling. +- Try to attach the main window to the desktop icon host. +- If that host is not found, use `HWND_BOTTOM`. +- On disable, restore the saved parent and styles as best effort. + +Non-Windows behavior: + +- Keep a null implementation. +- Log that the platform is unsupported. + +## Settings Flow + +The developer settings page owns confirmation UX for conflicts: + +- Fused desktop toggle and main-window desktop-layer toggle are one-way bound. +- Toggle click handlers ask for confirmation before saving conflicting states. +- The view model writes both keys together so runtime listeners receive a coherent change set. + +## Runtime Flow + +Main-window restore paths call `ActivateOrRefreshMainWindowLayer`. + +- If `EnableMainWindowDesktopLayer` is enabled, the app refreshes the desktop-layer attachment and hides the taskbar entry. +- If disabled, the app restores ordinary activation behavior, including the existing temporary foreground promotion. + +Settings changes call both fused desktop and main-window desktop-layer runtime application paths so switching modes is immediate. diff --git a/.trae/specs/main-window-desktop-layer/requirements.md b/.trae/specs/main-window-desktop-layer/requirements.md new file mode 100644 index 0000000..d18a030 --- /dev/null +++ b/.trae/specs/main-window-desktop-layer/requirements.md @@ -0,0 +1,20 @@ +# Main Window Desktop Layer + +## Requirements + +- Add a developer option named `EnableMainWindowDesktopLayer`. +- When enabled, the main Lan Mountain desktop window behaves like a desktop-surface window: ordinary application windows can stay above it. +- The feature is implemented as desktop-layer or bottom placement, not as `Topmost`. +- The option is mutually exclusive with `EnableFusedDesktop`. +- Enabling main-window desktop layer while fused desktop is enabled must ask for confirmation, then disable fused desktop on confirm or roll back on cancel. +- Enabling fused desktop while main-window desktop layer is enabled must ask for confirmation, then disable main-window desktop layer on confirm or roll back on cancel. +- Air APP windows remain ordinary application windows and must not be attached to the desktop layer. +- On Windows, the main window should attach to the desktop icon host when available and fall back to `HWND_BOTTOM` when unavailable. +- On non-Windows platforms, the setting may exist but the layer service is a no-op and must not throw. + +## Acceptance + +- Opening another app above Lan Mountain Desktop keeps that app visible when main-window desktop layer is enabled. +- Restoring the main window from tray keeps the desktop-layer behavior and does not perform a temporary `Topmost` promotion. +- Turning the option off restores normal main-window behavior as far as possible. +- Fused desktop component windows keep their existing bottom-most behavior and remain isolated from the main-window service. diff --git a/.trae/specs/main-window-desktop-layer/tasks.md b/.trae/specs/main-window-desktop-layer/tasks.md new file mode 100644 index 0000000..3380d54 --- /dev/null +++ b/.trae/specs/main-window-desktop-layer/tasks.md @@ -0,0 +1,10 @@ +# Main Window Desktop Layer Tasks + +- [x] Add `EnableMainWindowDesktopLayer` to app settings with a disabled default. +- [x] Add developer settings UI and localization strings. +- [x] Add confirmation flow for mutual exclusion with fused desktop. +- [x] Add a dedicated main-window desktop-layer service. +- [x] Wire main-window creation, restore, tray fallback, settings changes, and shutdown cleanup to the service. +- [x] Keep Air APP windows outside this layer service. +- [x] Add static regression tests for settings, restore paths, and service boundaries. +- [ ] Perform manual Windows z-order validation with real apps. diff --git a/.trae/specs/material-color-service/checklist.md b/.trae/specs/material-color-service/checklist.md new file mode 100644 index 0000000..d56410f --- /dev/null +++ b/.trae/specs/material-color-service/checklist.md @@ -0,0 +1,12 @@ +# Material Color Service Acceptance Checklist + +- [x] `dotnet build LanMountainDesktop.slnx -c Debug` succeeds. +- [x] `dotnet test LanMountainDesktop.slnx -c Debug` succeeds. +- [x] Material & Color page exposes color source, wallpaper source, system material, native event preference, polling interval, manual refresh, semantic color preview, and surface preview. +- [x] Appearance page no longer owns duplicate visible color/material controls. +- [x] Appearance page view model preserves Material & Color settings instead of rewriting them. +- [x] Component corner-radius settings preserve Material & Color fields instead of resetting them through old positional constructors. +- [x] Component editor receives colors from `MaterialColorSnapshot`. +- [x] Plugin SDK snapshot includes read-only color/material fields without breaking the existing constructor shape. +- [x] Wallpaper source selection supports auto, app, and system modes. +- [x] Native wallpaper event monitoring can be disabled and polling remains available. diff --git a/.trae/specs/material-color-service/spec.md b/.trae/specs/material-color-service/spec.md new file mode 100644 index 0000000..91f3d7e --- /dev/null +++ b/.trae/specs/material-color-service/spec.md @@ -0,0 +1,62 @@ +# Material Color Service + +## Goal + +Unify Monet seed extraction, wallpaper color extraction, semantic color roles, host material surfaces, and plugin appearance snapshots behind one host-owned material/color source of truth. + +## Scope + +- Host service: `IMaterialColorService` +- Compatibility facade: `IAppearanceThemeService` +- Settings page: `MaterialColorSettingsPage` +- Persisted settings: + - `ThemeColorMode` + - `ThemeColor` + - `SelectedWallpaperSeed` + - `SystemMaterialMode` + - `ThemeWallpaperColorSource` + - `UseNativeWallpaperChangeEvents` + - `SystemWallpaperRefreshIntervalSeconds` +- Plugin read-only appearance snapshot fields: + - accent color + - seed color + - color source + - system material mode + - semantic color roles + - material surfaces + - wallpaper seed candidates + +## Behavior + +`IMaterialColorService` owns the live `MaterialColorSnapshot`. Consumers should derive colors and material values from this snapshot instead of recalculating from raw theme settings, wallpaper settings, or `MonetPalette`. + +Supported color sources: + +- `default_neutral`: stable neutral surfaces with the default accent. +- `seed_monet`: user-selected seed color processed through Monet. +- `wallpaper_monet`: wallpaper colors processed through Monet. + +Wallpaper color source selection: + +- `auto`: app wallpaper or app solid color first, then system wallpaper, then fallback. +- `app`: app wallpaper or app solid color only, then fallback. +- `system`: system wallpaper only, then fallback. + +System wallpaper monitoring: + +- Native Windows user preference events are preferred when enabled and available. +- Polling remains active as the fallback path. +- Manual refresh clears cached wallpaper candidates and rebuilds the snapshot. + +## Refactor Rules + +- New consumers must depend on `IMaterialColorService`, not on parallel combinations of theme settings, wallpaper settings, and `MonetColorService`. +- `MonetColorService` remains the extraction/palette utility, not the application-wide coordinator. +- Component/editor/plugin appearance code must consume `MaterialColorSnapshot` or a mapper produced from it. +- Existing `IAppearanceThemeService` remains available for compatibility, but it must not become a second source of truth. + +## Out Of Scope + +- Plugin write access to global host appearance settings. +- Market metadata or sample plugin changes. +- Replacing the wallpaper picker page. It remains the asset/source management page. diff --git a/.trae/specs/material-color-service/tasks.md b/.trae/specs/material-color-service/tasks.md new file mode 100644 index 0000000..34f8121 --- /dev/null +++ b/.trae/specs/material-color-service/tasks.md @@ -0,0 +1,13 @@ +# Material Color Service Tasks + +- [x] Add unified material/color snapshot models and `IMaterialColorService`. +- [x] Persist wallpaper color source and native wallpaper event preference. +- [x] Add the Material & Color settings page. +- [x] Keep Appearance focused on theme mode, window chrome, and corner radius. +- [x] Route plugin appearance snapshots through the material/color snapshot. +- [x] Route component editor theming through the material/color snapshot. +- [x] Remove legacy color/material preview and save logic from the Appearance page view model. +- [x] Replace legacy positional `ThemeAppearanceSettingsState` writes with preserving `with` updates where found. +- [x] Keep native wallpaper events optional with polling/manual refresh fallback. +- [x] Add regression tests for normalization, plugin mapping, and component editor palette mapping. +- [ ] Continue retiring legacy direct consumers of raw theme/wallpaper/Monet tuples when they are touched. diff --git a/.trae/specs/pdc-incremental-migration/checklist.md b/.trae/specs/pdc-incremental-migration/checklist.md index 9212ae9..d53b8a5 100644 --- a/.trae/specs/pdc-incremental-migration/checklist.md +++ b/.trae/specs/pdc-incremental-migration/checklist.md @@ -1,13 +1,16 @@ # Checklist -- [ ] `release.yml` includes PDCC publish flow and does not invoke Velopack. -- [ ] `release.yml` uploads app payload artifacts for PDCC. -- [ ] S3 output path is rooted at `lanmountain/update/` (no system version prefix). -- [ ] S3 has `repo/`, `meta/`, and `installers/` outputs after a release run. -- [ ] Host update source default is `stcn` and old `pdc` values are auto-normalized. -- [ ] Host can persist PDC payload into launcher incoming directory. -- [ ] Launcher can apply PDC FileMap payload with signature/hash verification. -- [ ] Legacy signed `files.json + update.zip` path still works as compatibility fallback. +- [x] `release.yml` does not invoke Velopack. +- [x] `plonds-build.yml` uploads app payload artifacts and generates PloNDS delta/static outputs. +- [x] S3 output path is rooted at `lanmountain/update/` (no system version prefix). +- [x] CI workflow expects `repo/`, `meta/`, `manifests/`, and `installers/` outputs after a release run. +- [x] Host update source keeps compatibility (`pdc`/`stcn` normalize to active PloNDS source). +- [x] Host can persist PloNDS payload into launcher incoming directory. +- [x] Launcher can apply PloNDS FileMap payload with signature/hash verification. +- [x] Legacy signed `files.json + update.zip` path still works as compatibility fallback. +- [x] Launcher keeps rollback-capable deployments after successful update. +- [x] Manual rollback returns a structured failure when the snapshot source directory is missing. - [ ] CI run attached proving all release matrix jobs pass. -- [ ] N-1 -> N incremental update verified on Windows x64/x86 and Linux x64. -- [ ] Rollback verification report attached. +- [x] N-1 -> N incremental update verified locally on Windows x64. +- [ ] N-1 -> N incremental update verified on Windows x86 and Linux x64. +- [x] Rollback regression tests attached in `LanMountainDesktop.Tests`. diff --git a/.trae/specs/pdc-incremental-migration/spec.md b/.trae/specs/pdc-incremental-migration/spec.md index b9dfecb..16a549b 100644 --- a/.trae/specs/pdc-incremental-migration/spec.md +++ b/.trae/specs/pdc-incremental-migration/spec.md @@ -12,29 +12,33 @@ Replace VeloPack-based incremental packaging with a unified PDC FileMap + object ## Stage 2 (Current Implementation Target) -- Move release publishing to PDCC + `phainon.yml` (ClassIsland-style). -- Promote PDC-distributed FileMap/object-repo as the primary incremental path. +- Use GitHub Actions PloNDS static publishing as the active incremental path. +- Keep `phainon.yml` for future PDCC parity, but do not rely on PDCC for the current release flow. +- Promote PloNDS-distributed FileMap/object-repo as the primary incremental path. - Keep GitHub Release installers and metadata as parallel distribution. - Keep Launcher state machine ownership (`.current/.partial/.destroy` + snapshots). -- Update source defaults to `stcn` (S3/PDC), with GitHub fallback. +- Check updates in order: NS3/PloNDS static source, GitHub Release PloNDS assets, then GitHub full installer. - S3 object root is fixed to `lanmountain/update/` with no update-system version prefix. +- Public object URLs come from `S3_PUBLIC_BASE_URL`; do not infer them from `S3_ENDPOINT` and `S3_BUCKET`. Expected S3 layout: - - `lanmountain/update/repo//` - - `lanmountain/update/meta/channels///latest.json` - - `lanmountain/update/meta/distributions//*.json` - - `lanmountain/update/installers///*` + - `lanmountain/update/repo/sha256//` + - `lanmountain/update/meta/channels///latest.json` + - `lanmountain/update/meta/distributions/.json` + - `lanmountain/update/manifests//plonds-filemap.json` + - `lanmountain/update/manifests//plonds-filemap.json.sig` + - `lanmountain/update/installers///*` ## Acceptance -- `release.yml` includes PDCC publish steps and no Velopack steps. +- `release.yml` contains no Velopack steps; PloNDS static publishing is handled by `plonds-build.yml` and `ddss-publish.yml`. - Release jobs keep building installers for Windows x64/x86, Linux x64, and macOS. -- PDC metadata + FileMap + object repo are published under `lanmountain/update/`. -- Host can consume PDC payload (`stcn` source) and fallback to GitHub when unavailable. +- PloNDS metadata + FileMap + object repo are published under `lanmountain/update/`. +- Host can consume the NS3/PloNDS static payload and fallback to GitHub when unavailable. - Launcher can apply both: - legacy signed `files.json + update.zip` - - PDC FileMap object-repo payload. -- Rollback semantics remain unchanged. + - PloNDS FileMap object-repo payload. +- Rollback semantics keep both automatic failure rollback and manual rollback after a successful update. ## Deprecated Notes diff --git a/.trae/specs/pdc-incremental-migration/tasks.md b/.trae/specs/pdc-incremental-migration/tasks.md index 02998af..f195f02 100644 --- a/.trae/specs/pdc-incremental-migration/tasks.md +++ b/.trae/specs/pdc-incremental-migration/tasks.md @@ -3,13 +3,19 @@ - [x] Remove VeloPack packaging from release workflow. - [x] Keep signed FileMap path as interim compatibility fallback. - [x] Remove launcher/runtime Velopack branching. -- [ ] Add `phainon.yml` for PDCC publish configuration. -- [ ] Add PDCC installation + publish steps in `release.yml`. -- [ ] Upload app payload artifacts for PDCC consumption in release build jobs. -- [ ] Publish PDC metadata + object repo to S3 path root `lanmountain/update/`. -- [ ] Mirror installers to `lanmountain/update/installers///`. -- [ ] Replace update source canonical value with `stcn` (keep legacy `pdc` compatibility). -- [ ] Add PDC payload model into host update check result. -- [ ] Add host download path for PDC payload (`pdc-filemap.json` + signature + metadata). -- [ ] Add launcher PDC FileMap apply path with rollback-compatible semantics. -- [ ] Keep old `files.json + update.zip` path behind compatibility fallback. +- [x] Add `phainon.yml` for PDCC publish configuration. +- [ ] Add PDCC installation + publish steps in `release.yml` (deferred; active path is GitHub Actions PloNDS static publish). +- [x] Upload app payload artifacts for PloNDS delta generation in release build jobs. +- [x] Publish PloNDS metadata + sha256 object repo to S3 path root `lanmountain/update/`. +- [x] Mirror installers to `lanmountain/update/installers///`. +- [x] Keep update source compatibility (`pdc`/`stcn` normalize to active PloNDS source). +- [x] Add PloNDS static payload model into host update check result. +- [x] Add host download path for PloNDS payload (`plonds-filemap.json` + signature + object repo). +- [x] Add launcher PloNDS FileMap apply path with rollback-compatible semantics. +- [x] Keep old `files.json + update.zip` path behind compatibility fallback. +- [x] Keep rollback deployment directories after successful apply and prune by bounded retention. +- [x] Return structured failure when manual rollback snapshot source is missing. +- [x] Verify static S3 layout, filemap/signature, distribution, latest pointer, and at least one object in CI workflows. +- [x] Add regression tests for PloNDS success rollback, hash-failure auto rollback, missing rollback source, static NS3 manifest, and manifest field mapping. +- [ ] Attach live CI run proving the full release matrix passes. +- [ ] Verify N-1 -> N incremental update on Windows x86 and Linux x64 in release artifacts. diff --git a/.trae/specs/settings-window-fluent-shell-redesign/spec.md b/.trae/specs/settings-window-fluent-shell-redesign/spec.md new file mode 100644 index 0000000..709207d --- /dev/null +++ b/.trae/specs/settings-window-fluent-shell-redesign/spec.md @@ -0,0 +1,25 @@ +# Settings Window Fluent Shell Redesign + +## Goal + +Rebuild the settings window as an independent Fluent shell with a custom titlebar, titlebar hamburger menu, persistent side navigation, search, and Avalonia-standard system material support. + +## Requirements + +- Keep the existing independent settings-window lifecycle: open-or-focus, no owner anchor, own taskbar entry. +- Use a 48 DIP titlebar with Back, pane toggle, icon/title, search, restart action, more menu, and caption-button spacer. +- Keep `FANavigationView` as the primary navigation surface with `OpenPaneLength` around 283 DIP. +- Move the compact/minimal pane toggle from the navigation footer into the titlebar. +- Add search over built-in settings pages and settings expanders; selecting a result navigates, expands, focuses, and highlights. +- Add `auto` system material mode and make it the default. +- Implement material with Avalonia `TransparencyLevelHint` only. +- Preserve settings page layout as direct `ScrollViewer -> StackPanel -> FASettingsExpander` content. +- Follow `docs/VISUAL_SPEC.md`, `docs/CORNER_RADIUS_SPEC.md`, and `docs/ai/SETTINGS_WINDOW_DESIGN.md`. + +## Acceptance + +- `dotnet build LanMountainDesktop.slnx -c Debug` succeeds. +- `dotnet test LanMountainDesktop.slnx -c Debug` succeeds or any unrelated failures are documented. +- The settings window can navigate by sidebar, titlebar Back, titlebar pane toggle, and search. +- Appearance settings expose Auto, None, Mica, and/or Acrylic according to system support. +- Existing dirty user changes are not reverted. diff --git a/.trae/specs/settings-window-fluent-shell-redesign/tasks.md b/.trae/specs/settings-window-fluent-shell-redesign/tasks.md new file mode 100644 index 0000000..480e365 --- /dev/null +++ b/.trae/specs/settings-window-fluent-shell-redesign/tasks.md @@ -0,0 +1,13 @@ +# Tasks + +- [x] Analyze current `SettingsWindow`, appearance theme service, and existing settings page layout. +- [x] Compare ClassIsland `SettingsWindowNew` and SecRandom v3 Avalonia `SettingsView`. +- [x] Replace footer fallback pane toggle with titlebar pane toggle. +- [x] Add titlebar Back, search, restart, and more-options controls. +- [x] Add settings navigation history. +- [x] Add settings search service and result highlight. +- [x] Add `auto` system material mode and Avalonia `TransparencyLevelHint` priority. +- [x] Update appearance settings options and localization. +- [x] Add focused tests for material normalization and search filtering. +- [x] Add design/spec documentation. +- [ ] Run full app manually on Windows 11 and Windows 10 to verify actual Mica/Acrylic backdrops. diff --git a/.trae/specs/update-settings-fluent-controls/spec.md b/.trae/specs/update-settings-fluent-controls/spec.md new file mode 100644 index 0000000..5ae7589 --- /dev/null +++ b/.trae/specs/update-settings-fluent-controls/spec.md @@ -0,0 +1,25 @@ +# Update Settings Fluent Controls + +## Goal + +Make the Settings > Update page the single user-facing control surface for the host update flow. + +## Requirements + +- The page uses Fluent Avalonia settings controls for update status, release facts, update behavior, and transfer controls. +- Users can choose update channel, download source, update mode, and download thread count. +- Update mode options are: + - Manual: do not automatically download or install. + - Silent Download: check and download in the background, then wait for user installation confirmation. + - Silent Install: check and download in the background, then apply when the app exits. +- Users can opt into forced reinstall. When enabled, the update check targets the current version manifest where available and the UI labels the next payload as reinstall. +- The page displays whether the current payload is an incremental update or reinstall/full installer. +- The page exposes pause, resume, and cancel actions for resumable downloads and install recovery. +- Existing PloNDS/FileMap incremental update and Launcher rollback ownership remain unchanged. + +## Acceptance + +- `UpdateSettingsPage` shows Fluent Avalonia controls for channel, mode, thread count, forced reinstall, pause/resume, and cancel. +- `UpdateSettingsState` persists forced reinstall alongside other update preferences. +- Automatic startup checks skip manual mode, download in silent download/silent install modes, and leave installation to explicit user action or exit-time apply. +- Build succeeds for `LanMountainDesktop.slnx`. diff --git a/.trae/specs/window-layer-isolation/checklist.md b/.trae/specs/window-layer-isolation/checklist.md new file mode 100644 index 0000000..480d190 --- /dev/null +++ b/.trae/specs/window-layer-isolation/checklist.md @@ -0,0 +1,7 @@ +# Checklist + +- [x] Air APP window code does not call fused desktop bottom-most APIs. +- [x] Air APP window code does not set `Topmost = true`. +- [x] Fused desktop overlay and widget windows still use bottom-most APIs. +- [x] Fused desktop widget reload path refreshes desktop layer after showing. +- [ ] Manual Windows z-order verification with fused desktop and Air APP windows. diff --git a/.trae/specs/window-layer-isolation/spec.md b/.trae/specs/window-layer-isolation/spec.md new file mode 100644 index 0000000..ae6cf9a --- /dev/null +++ b/.trae/specs/window-layer-isolation/spec.md @@ -0,0 +1,18 @@ +# Window Layer Isolation + +## Goal + +Keep fused desktop component windows and Air APP windows in separate z-order roles. + +## Behavior + +- Fused desktop windows are desktop-surface windows. They may use `IWindowBottomMostService` and region passthrough, must stay attached to the Windows desktop icon host when supported, and must not cover ordinary apps. +- Air APP windows are ordinary application windows. They must not use the fused desktop bottom-most service, must not attach to the desktop icon host, and must not use global `Topmost` promotion. +- Re-showing or reloading fused desktop widgets refreshes their desktop layer after the window is visible. +- Air APP activation uses normal window activation; repeated-open foreground recovery remains owned by Launcher lifecycle activation. + +## Out of Scope + +- Changing Air APP lifecycle IPC. +- Changing whiteboard note sharing. +- Implementing third-party Air APP SDK behavior. diff --git a/.trae/specs/window-layer-isolation/tasks.md b/.trae/specs/window-layer-isolation/tasks.md new file mode 100644 index 0000000..bf393e5 --- /dev/null +++ b/.trae/specs/window-layer-isolation/tasks.md @@ -0,0 +1,7 @@ +# Tasks + +- [x] Remove Air APP `Topmost` promotion from `AirAppWindow`. +- [x] Add explicit desktop-layer refresh for fused desktop widget windows. +- [x] Refresh fused desktop widget windows after show/reload. +- [x] Add window-role diagnostics for desktop-surface and Air APP windows. +- [x] Add static regression tests for window-layer isolation. diff --git a/CODE_WIKI.md b/CODE_WIKI.md new file mode 100644 index 0000000..1d0712d --- /dev/null +++ b/CODE_WIKI.md @@ -0,0 +1,874 @@ +# LanMountainDesktop Code Wiki + +> 本文档是 LanMountainDesktop(阑山桌面)项目的结构化 Code Wiki,涵盖项目整体架构、主要模块职责、关键类与函数说明、依赖关系以及项目运行方式等关键信息。 +> +> 生成日期:2026-05-07 +> 产品版本:1.0.0 +> Plugin SDK API 基线:5.0.0 + +--- + +## 目录 + +1. [项目概述](#1-项目概述) +2. [整体架构](#2-整体架构) +3. [项目结构与模块职责](#3-项目结构与模块职责) +4. [关键类与函数说明](#4-关键类与函数说明) +5. [依赖关系](#5-依赖关系) +6. [项目运行方式](#6-项目运行方式) +7. [启动流程详解](#7-启动流程详解) +8. [插件系统架构](#8-插件系统架构) +9. [数据流与交互模型](#9-数据流与交互模型) +10. [测试体系](#10-测试体系) + +--- + +## 1. 项目概述 + +### 1.1 产品定位 + +**阑山桌面(LanMountainDesktop)** 是一款跨平台桌面环境增强工具,基于 Avalonia UI 和 .NET 10 构建。 + +- **产品口号**:你的桌面,不止一面 +- **技术基线**:Avalonia UI + .NET 10 +- **支持平台**:Windows、Linux、macOS +- **仓库角色**:桌面宿主、插件运行时、Plugin SDK 与共享契约的权威来源 + +### 1.2 目标用户 + +- **学生用户**:课程表、自习监测、计时、天气和日常信息聚合 +- **办公用户**:日历、资讯、最近文档、常用工具入口 +- **效率和美化爱好者**:自由布局、主题切换、插件扩展 +- **中文用户**:本地化界面、农历和节假日等本地语境支持 + +### 1.3 核心能力 + +- **桌面组件系统**:内置组件与扩展组件统一注册、统一放置约束 +- **插件系统**:宿主加载插件、整合设置页、组件与市场安装流 +- **外观系统**:主题、玻璃层级、圆角与颜色资源统一管理 +- **设置系统**:独立设置窗口、设置页注册与分域持久化 +- **跨平台运行**:基于 Avalonia 的桌面宿主运行在 Windows、Linux、macOS + +### 1.4 生态边界 + +| 仓库 | 职责 | +|------|------| +| `LanMountainDesktop`(本仓库) | 宿主代码、插件运行时、SDK、共享契约、主题与设置基础设施 | +| `LanAirApp`(兄弟仓库) | 插件市场元数据、开发者生态材料 | +| `LanMountainDesktop.SamplePlugin` | 官方示例插件实现 | + +--- + +## 2. 整体架构 + +### 2.1 架构分层 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 用户界面层 (UI Layer) │ +│ Views/ │ ViewModels/ │ Theme/ │ Styles/ │ Localization/ +├─────────────────────────────────────────────────────────────┤ +│ 业务服务层 (Service Layer) │ +│ Services/ │ ComponentSystem/ │ DesktopEditing/ │ plugins/ +├─────────────────────────────────────────────────────────────┤ +│ 基础设施层 (Infrastructure) │ +│ DesktopHost/ │ Appearance/ │ Settings.Core/ │ Shared.IPC/ +├─────────────────────────────────────────────────────────────┤ +│ 抽象与契约层 (Abstractions) │ +│ Host.Abstractions/ │ Shared.Contracts/ │ PluginSdk/ +├─────────────────────────────────────────────────────────────┤ +│ 启动与更新层 (Launcher) │ +│ LanMountainDesktop.Launcher/ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 核心设计原则 + +1. **插件优先**:核心功能通过插件扩展,宿主提供运行时和基础设施 +2. **组件化桌面**:所有桌面元素都是组件,统一注册、统一放置 +3. **设置分域**:App / Launcher / ComponentInstance / Plugin 四级设置作用域 +4. **主题动态化**:支持 Material Design 3 动态配色、系统主题跟随 +5. **进程隔离预留**:当前为进程内加载,预留了隔离进程架构 + +--- + +## 3. 项目结构与模块职责 + +### 3.1 解决方案项目列表 + +| 项目路径 | 输出类型 | 主要职责 | +|---------|---------|---------| +| `LanMountainDesktop/` | WinExe | 主桌面宿主应用,包含 UI、服务、组件系统、插件运行时接入 | +| `LanMountainDesktop.Launcher/` | WinExe | 启动器 - 负责 OOBE、Splash、版本管理、增量更新、插件安装 | +| `LanMountainDesktop.PluginSdk/` | Library | 官方插件 SDK,定义插件可依赖的公开接口与打包行为 | +| `LanMountainDesktop.Shared.Contracts/` | Library | 宿主与插件共享的稳定契约类型 | +| `LanMountainDesktop.Shared.IPC/` | Library | 统一 IPC 基础,用于 Host 公共服务、Launcher/OOBE 启动通知、插件贡献的公共服务 | +| `LanMountainDesktop.Appearance/` | Library | 主题、圆角、外观资源相关基础设施 | +| `LanMountainDesktop.Settings.Core/` | Library | 设置域、持久化和设置基础抽象 | +| `LanMountainDesktop.DesktopHost/` | Library | 桌面宿主流程与生命周期相关逻辑 | +| `LanMountainDesktop.DesktopComponents.Runtime/` | Library | 组件运行时支撑能力 | +| `LanMountainDesktop.Host.Abstractions/` | Library | 宿主侧抽象接口 | +| `LanMountainDesktop.PluginIsolation.Contracts/` | Library | 插件隔离机制的传输无关 DTO、路由常量、错误码 | +| `LanMountainDesktop.PluginIsolation.Ipc/` | Library | 插件隔离 IPC 外观,基于 dotnetCampus.Ipc | +| `LanMountainDesktop.PluginTemplate/` | Library | `dotnet new lmd-plugin` 官方模板 | +| `LanMountainDesktop.PluginUpgradeHelper/` | Library | 插件升级帮助程序 | +| `LanMountainDesktop.Tests/` | Test | 宿主与 SDK 的测试项目 | + +### 3.2 主宿主工程内部结构 + +``` +LanMountainDesktop/ +├── Program.cs # 进程启动主线 +├── App.axaml.cs # 应用初始化、主题、语言、托盘、插件运行时 +├── Views/ # 界面视图 +│ ├── MainWindow.axaml # 主窗口 +│ ├── SettingsWindow.axaml # 设置窗口 +│ ├── ComponentLibraryWindow.axaml # 组件库窗口 +│ ├── FusedDesktopComponentLibraryWindow.axaml # 融合桌面组件库 +│ ├── NotificationWindow.axaml # 通知窗口 +│ ├── TransparentOverlayWindow.axaml # 透明覆盖层窗口 +│ ├── SettingsPages/ # 设置页面 +│ ├── Components/ # 桌面组件视图 +│ └── ComponentEditors/ # 组件编辑器视图 +├── ViewModels/ # 视图模型 +│ ├── MainWindowViewModel.cs +│ ├── ViewModelBase.cs +│ └── ... +├── Services/ # 业务服务层 +│ ├── AppearanceThemeService.cs # 外观主题服务 +│ ├── Settings/ # 设置相关服务 +│ ├── MaterialColorService.cs # Material 颜色服务 +│ ├── DesktopTrayService.cs # 桌面托盘服务 +│ ├── FusedDesktopManagerService.cs # 融合桌面管理 +│ └── ... +├── ComponentSystem/ # 组件系统 +│ ├── ComponentRegistry.cs # 组件注册表 +│ ├── DesktopComponentDefinition.cs # 组件定义 +│ └── ... +├── plugins/ # 插件运行时 +│ ├── PluginRuntimeService.cs # 插件运行时服务 +│ ├── PluginLoader.cs # 插件加载器 +│ └── ... +├── Theme/ # 主题资源 +├── Styles/ # 样式规则 +├── DesktopEditing/ # 桌面布局编辑 +├── Localization/ # 本地化资源 +└── Models/ # 数据模型 +``` + +### 3.3 Launcher 工程结构 + +``` +LanMountainDesktop.Launcher/ +├── Program.cs # 启动器入口 +├── App.axaml.cs # 启动器应用初始化 +├── Views/ # 启动器视图 +│ ├── OobeWindow.axaml # 首次体验窗口 +│ └── SplashWindow.axaml # 启动动画窗口 +└── Services/ # 启动器服务 + ├── DeploymentLocator.cs # 版本目录定位 + ├── UpdateCheckService.cs # 更新检查 + ├── UpdateEngineService.cs # 更新引擎 + ├── LauncherFlowCoordinator.cs # 流程协调器 + ├── OobeStateService.cs # OOBE 状态管理 + ├── PluginInstallerService.cs # 插件安装 + └── PluginUpgradeQueueService.cs # 插件升级队列 +``` + +--- + +## 4. 关键类与函数说明 + +### 4.1 应用程序入口与生命周期 + +#### `Program`(LanMountainDesktop/Program.cs) + +**职责**:应用程序入口点,负责启动初始化、单实例控制、资源加载、渲染模式配置、日志初始化。 + +**关键属性**: + +```csharp +internal static string StartupRenderMode { get; private set; } = AppRenderingModeHelper.Default; +``` + +**关键方法**: + +| 方法 | 签名 | 说明 | +|------|------|------| +| `Main` | `public static void Main(string[] args)` | 应用入口,初始化日志、单实例、遥测,构建 Avalonia AppBuilder | +| `BuildAvaloniaApp` | `public static AppBuilder BuildAvaloniaApp(string renderMode)` | 构建 Avalonia 应用,配置 Win32 渲染模式 | +| `AcquireSingleInstance` | `private static SingleInstanceService AcquireSingleInstance(int? restartParentProcessId)` | 获取单实例锁,支持重启场景 | +| `LoadConfiguredRenderMode` | `private static string LoadConfiguredRenderMode()` | 从设置加载配置的渲染模式 | +| `RegisterGlobalExceptionLogging` | `private static void RegisterGlobalExceptionLogging()` | 注册全局未处理异常日志和遥测 | + +#### `App`(LanMountainDesktop/App.axaml.cs) + +**职责**:应用启动和生命周期管理,包含应用初始化、主窗口管理、插件运行时初始化、主题设置、设置系统初始化。 + +**关键属性**: + +```csharp +internal static SingleInstanceService? CurrentSingleInstanceService { get; set; } +internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle { get; } +internal static INotificationService? CurrentNotificationService { get; } +public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService; +public ISettingsFacadeService SettingsFacade => _settingsFacade; +``` + +**关键方法**: + +| 方法 | 签名 | 说明 | +|------|------|------| +| `Initialize` | `public override void Initialize()` | 初始化应用资源、主题、语言、设置服务 | +| `OnFrameworkInitializationCompleted` | `public override void OnFrameworkInitializationCompleted()` | 框架初始化完成后调用,初始化 IPC、桌面壳层 | +| `InitializeDesktopShell` | `private void InitializeDesktopShell()` | 初始化桌面壳层,包括插件运行时、托盘、主窗口 | +| `OpenIndependentSettingsModule` | `internal void OpenIndependentSettingsModule(string source, string? pageTag)` | 打开独立设置窗口 | +| `ActivateMainWindow` | `internal void ActivateMainWindow()` | 激活主窗口 | + +### 4.2 插件系统 + +#### `PluginRuntimeService`(LanMountainDesktop/plugins/PluginRuntimeService.cs) + +**职责**:插件系统的核心运行时类,负责插件的加载、卸载、管理、依赖注入、插件贡献点注册。 + +**关键属性**: + +```csharp +public string PluginsDirectory { get; } // 插件目录路径 +public IReadOnlyList LoadedPlugins { get; } // 已加载插件列表 +public IReadOnlyList LoadResults { get; } // 加载结果列表 +public IReadOnlyList Catalog { get; } // 插件目录 +public IReadOnlyList SettingsSections { get; } // 设置页贡献 +public IReadOnlyList DesktopComponents { get; } // 组件贡献 +public IReadOnlyList DesktopComponentEditors { get; } // 编辑器贡献 +``` + +**关键方法**: + +| 方法 | 签名 | 说明 | +|------|------|------| +| `LoadInstalledPlugins` | `public void LoadInstalledPlugins()` | 加载所有已安装插件 | +| `SetPluginEnabled` | `public bool SetPluginEnabled(string pluginId, bool isEnabled)` | 启用/禁用插件 | +| `InstallPluginPackage` | `public PluginManifest InstallPluginPackage(string packagePath)` | 安装插件包(.laapp) | +| `DeleteInstalledPlugin` | `public bool DeleteInstalledPlugin(string pluginId)` | 删除已安装插件 | + +#### `IPlugin`(LanMountainDesktop.PluginSdk/IPlugin.cs) + +**职责**:插件接口,定义了插件的基本生命周期和能力。插件必须实现此接口以被宿主识别和加载。 + +```csharp +public interface IPlugin +{ + void Initialize(HostBuilderContext context, IServiceCollection services); +} +``` + +#### `PluginBase`(LanMountainDesktop.PluginSdk/PluginBase.cs) + +**职责**:插件基类,提供了插件开发的基础实现。 + +```csharp +public abstract class PluginBase : IPlugin +{ + public virtual void Initialize(HostBuilderContext context, IServiceCollection services) { } +} +``` + +#### `PluginManifest`(LanMountainDesktop.PluginSdk/PluginManifest.cs) + +**职责**:插件清单信息类,包含插件的元数据。 + +```csharp +public sealed record PluginManifest( + string Id, // 插件唯一标识 + string Name, // 插件名称 + string EntranceAssembly, // 入口程序集 + string? Description = null, // 描述 + string? Author = null, // 作者 + string? Version = null, // 版本 + string? ApiVersion = null, // API 版本 + IReadOnlyList? SharedContracts = null, + PluginRuntimeConfiguration? Runtime = null) +``` + +**关键方法**: + +| 方法 | 签名 | 说明 | +|------|------|------| +| `Load` | `public static PluginManifest Load(string manifestPath)` | 从文件加载插件清单 | +| `ResolveEntranceAssemblyPath` | `public string ResolveEntranceAssemblyPath(string manifestPath)` | 解析入口程序集路径 | + +### 4.3 设置系统 + +#### `SettingsService`(LanMountainDesktop/Services/Settings/SettingsService.cs) + +**职责**:设置系统的核心服务,管理应用和插件的设置数据持久化、读取和保存、设置变更监听。 + +**关键属性**: + +```csharp +public event EventHandler? Changed; // 设置变更事件 +``` + +**关键方法**: + +| 方法 | 签名 | 说明 | +|------|------|------| +| `LoadSnapshot` | `public T LoadSnapshot(SettingsScope scope, string? subjectId = null, string? placementId = null)` | 加载设置快照 | +| `SaveSnapshot` | `public void SaveSnapshot(SettingsScope scope, T snapshot, ...)` | 保存设置快照 | +| `LoadSection` | `public T LoadSection(SettingsScope scope, string subjectId, string sectionId, ...)` | 加载设置节 | +| `SaveSection` | `public void SaveSection(SettingsScope scope, string subjectId, string sectionId, T section, ...)` | 保存设置节 | +| `GetValue` | `public T? GetValue(SettingsScope scope, string key, ...)` | 获取单个值 | +| `SetValue` | `public void SetValue(SettingsScope scope, string key, T value, ...)` | 设置单个值 | +| `GetComponentAccessor` | `public IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId)` | 获取组件设置访问器 | + +**设置作用域(SettingsScope)**: + +| 作用域 | 说明 | +|--------|------| +| `App` | 应用级设置 | +| `Launcher` | 启动器设置 | +| `ComponentInstance` | 组件实例设置 | +| `Plugin` | 插件设置 | + +### 4.4 外观主题系统 + +#### `IAppearanceThemeService`(LanMountainDesktop/Services/AppearanceThemeService.cs) + +**职责**:外观主题服务接口,定义了主题获取、预览构建、资源应用等方法。 + +```csharp +public interface IAppearanceThemeService +{ + AppearanceThemeSnapshot GetCurrent(); + AppearanceThemeSnapshot BuildPreview(ThemeAppearanceSettingsState pendingState); + event EventHandler? Changed; + void ApplyThemeResources(IResourceDictionary resources); + AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role); + void ApplyWindowMaterial(Window window, MaterialSurfaceRole role); +} +``` + +#### `AppearanceThemeService` + +**职责**:外观主题服务的实现,委托给 `MaterialColorService` 处理具体逻辑。 + +**关键方法**: + +| 方法 | 签名 | 说明 | +|------|------|------| +| `GetCurrent` | `public AppearanceThemeSnapshot GetCurrent()` | 获取当前主题快照 | +| `BuildPreview` | `public AppearanceThemeSnapshot BuildPreview(ThemeAppearanceSettingsState pendingState)` | 构建主题预览 | +| `ApplyThemeResources` | `public void ApplyThemeResources(IResourceDictionary resources)` | 应用主题资源到资源字典 | +| `GetMaterialSurface` | `public AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role)` | 获取材质表面配置 | +| `ApplyWindowMaterial` | `public void ApplyWindowMaterial(Window window, MaterialSurfaceRole role)` | 应用窗口材质效果 | + +**材质表面角色(MaterialSurfaceRole)**: + +| 角色 | 说明 | +|------|------| +| `WindowBackground` | 窗口背景 | +| `SettingsWindowBackground` | 设置窗口背景 | +| `DockBackground` | 停靠栏背景 | +| `StatusBarBackground` | 状态栏背景 | +| `DesktopComponentHost` | 桌面组件宿主 | +| `StatusBarComponentHost` | 状态栏组件宿主 | +| `OverlayPanel` | 覆盖层面板 | + +### 4.5 桌面宿主 + +#### `DesktopBootstrap`(LanMountainDesktop.DesktopHost/DesktopBootstrap.cs) + +**职责**:桌面启动引导,协调启动服务初始化和应用初始化。 + +```csharp +public static class DesktopBootstrap +{ + public static void InitializeStartupServices( + Action initializeTelemetryIdentity, + Action initializeCrashTelemetry, + Action initializeUsageTelemetry, + Action scheduleStartupCleanup); + + public static void InitializeApplication(Application application, Action initializeShell); +} +``` + +### 4.6 Launcher 核心服务 + +#### `DeploymentLocator`(LanMountainDesktop.Launcher/Services/DeploymentLocator.cs) + +**职责**:扫描和定位 `app-*` 版本目录,选择最佳版本。 + +**版本选择算法**: +1. 扫描所有 `app-*` 目录 +2. 过滤掉带 `.destroy` 或 `.partial` 标记的目录 +3. 优先选择带 `.current` 标记的版本 +4. 如果没有 `.current`,选择版本号最高的 + +#### `UpdateEngineService` + +**职责**:下载、验证、应用增量更新,支持原子化更新和回滚。 + +#### `LauncherFlowCoordinator` + +**职责**:协调 OOBE → Splash → 更新 → 插件 → 启动主程序的完整流程。 + +--- + +## 5. 依赖关系 + +### 5.1 项目间依赖图 + +``` +LanMountainDesktop (主程序) +├── LanMountainDesktop.Host.Abstractions +├── LanMountainDesktop.Shared.Contracts +├── LanMountainDesktop.Shared.IPC +├── LanMountainDesktop.Settings.Core +├── LanMountainDesktop.Appearance +├── LanMountainDesktop.DesktopComponents.Runtime +├── LanMountainDesktop.DesktopHost +├── LanMountainDesktop.PluginSdk +└── ThirdParty/DotNetCampus.InkCanvas + +LanMountainDesktop.Launcher (启动器) +├── LanMountainDesktop.Shared.Contracts +├── LanMountainDesktop.Shared.IPC +└── LanMountainDesktop.Settings.Core + +LanMountainDesktop.PluginSdk (插件SDK) +└── (无项目引用,纯公共接口) + +LanMountainDesktop.DesktopHost +├── LanMountainDesktop.Host.Abstractions +└── LanMountainDesktop.Shared.Contracts + +LanMountainDesktop.Appearance +├── LanMountainDesktop.Settings.Core +└── LanMountainDesktop.Shared.Contracts + +LanMountainDesktop.DesktopComponents.Runtime +├── LanMountainDesktop.Host.Abstractions +└── LanMountainDesktop.Shared.Contracts + +LanMountainDesktop.PluginIsolation.Ipc +├── LanMountainDesktop.PluginIsolation.Contracts +└── LanMountainDesktop.Shared.IPC +``` + +### 5.2 主要 NuGet 依赖 + +| 包名 | 版本 | 用途 | +|------|------|------| +| Avalonia | 12.0.2 | 跨平台 UI 框架 | +| Avalonia.Controls.WebView | 12.0.0 | WebView 控件 | +| Avalonia.Desktop | 12.0.2 | 桌面平台支持 | +| Avalonia.Themes.Fluent | 12.0.2 | Fluent 主题 | +| FluentAvaloniaUI | 3.0.0-preview2 | Fluent UI 控件库 | +| Material.Avalonia | 3.16.1 | Material Design 控件 | +| MaterialColorUtilities | 0.3.0 | Material Design 3 动态配色 | +| CommunityToolkit.Mvvm | 8.4.2 | MVVM 工具包 | +| Microsoft.Extensions.DependencyInjection | 11.0.0-preview | 依赖注入 | +| Microsoft.Extensions.Hosting.Abstractions | 11.0.0-preview | 宿主抽象 | +| Microsoft.Data.Sqlite | 11.0.0-preview | SQLite 数据库 | +| PostHog | 2.6.0 | 使用遥测 | +| Sentry | 6.4.1 | 崩溃遥测 | +| Downloader | 5.4.0 | 文件下载 | +| Lib.Harmony.Thin | 2.4.2 | 运行时方法拦截 | +| log4net | 3.3.1 | 日志记录 | + +--- + +## 6. 项目运行方式 + +### 6.1 环境准备 + +- 安装 **.NET SDK 10**(由 `global.json` 锁定版本 `10.0.103`) +- 桌面端建议优先在 Windows 上开发和验证 +- 仓库主入口解决方案文件为 `LanMountainDesktop.slnx` + +### 6.2 常用命令 + +#### 还原与构建 + +```bash +dotnet restore +dotnet build LanMountainDesktop.slnx -c Debug +``` + +#### 运行桌面宿主(开发模式) + +```bash +# 直接运行主程序,跳过 Launcher +dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj +``` + +#### 运行桌面宿主(生产模式) + +```bash +# 先构建 Launcher +dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c Debug + +# 通过 Launcher 启动主程序 +dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch +``` + +#### Launcher 其他命令 + +```bash +# 检查更新 +dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update check + +# 安装插件 +dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- plugin install + +# 版本回退 +dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update rollback +``` + +#### 运行测试 + +```bash +dotnet test LanMountainDesktop.slnx -c Debug +``` + +#### 插件本地包生成 + +```powershell +./scripts/Pack-PluginPackages.ps1 +``` + +### 6.3 Linux 录音依赖 + +如果在 Linux 上使用录音机或自习监测相关能力,需要安装音频库: + +```bash +# Debian/Ubuntu +sudo apt install libportaudio2 libasound2 + +# Fedora/RHEL +sudo dnf install portaudio-libs alsa-lib + +# Arch Linux +sudo pacman -S portaudio alsa-lib + +# Alpine Linux +sudo apk add portaudio alsa-lib +``` + +--- + +## 7. 启动流程详解 + +### 7.1 生产环境启动流程(通过 Launcher) + +``` +用户启动 LanMountainDesktop.Launcher.exe + │ + ▼ +Launcher 扫描 app-* 目录,选择最佳版本 +(优先 .current 标记,然后按版本号降序) + │ + ▼ +首次启动?→ 显示 OOBE 引导(OobeWindow) + │ + ▼ +显示 Splash 启动动画(SplashWindow) + │ + ▼ +检查并应用待处理的更新(UpdateEngineService.ApplyPendingUpdate) + │ + ▼ +处理插件升级队列(PluginUpgradeQueueService) + │ + ▼ +启动主程序 app-{version}/LanMountainDesktop.exe + │ + ▼ +清理标记为 .destroy 的旧版本 +``` + +### 7.2 主程序启动流程(LanMountainDesktop.exe) + +``` +Program.cs Main() + │ + ├── 初始化日志(AppLogger.Initialize) + ├── 初始化应用数据路径(AppDataPathProvider.Initialize) + ├── 解析开发插件选项(DevPluginOptions.Parse) + ├── 注册全局异常日志 + └── 获取重启父进程 ID + │ + ▼ +获取单实例锁(SingleInstanceService) + │ + ├── 非主实例?→ 通知主实例并退出 + └── 是主实例?→ 继续 + │ + ▼ +初始化启动服务(DesktopBootstrap.InitializeStartupServices) + │ + ├── 初始化遥测身份(TelemetryIdentityService) + ├── 初始化崩溃遥测(SentryCrashTelemetryService) + ├── 初始化使用遥测(PostHogUsageTelemetryService) + └── 调度白板笔记启动清理 + │ + ▼ +运行启动诊断(StartupDiagnosticsService.Run) + │ + ▼ +加载配置的渲染模式(LoadConfiguredRenderMode) + │ + ▼ +构建 Avalonia AppBuilder(BuildAvaloniaApp) + │ + ▼ +进入 App.axaml.cs + │ + ├── 初始化主题(ApplyThemeFromSettings) + ├── 初始化语言(ApplyCurrentCultureFromSettings) + ├── 初始化设置窗口服务(EnsureSettingsWindowService) + ├── 初始化天气定位刷新(EnsureWeatherLocationRefreshService) + └── 初始化通知服务(EnsureNotificationService) + │ + ▼ +框架初始化完成(OnFrameworkInitializationCompleted) + │ + ├── 初始化公共 IPC(InitializePublicIpc) + ├── 启动单实例激活监听 + ├── 初始化 Launcher IPC(InitializeLauncherIpcAsync) + └── 初始化桌面壳层(InitializeDesktopShell) + │ + ▼ +桌面壳层初始化 + │ + ├── 初始化插件运行时(InitializePluginRuntime) + ├── 初始化托盘图标(InitializeTrayIcon) + ├── 创建主窗口(CreateAndAssignMainWindow) + └── 启动天气定位刷新 +``` + +### 7.3 版本目录结构 + +``` +安装根目录/ +├── LanMountainDesktop.Launcher.exe ← 唯一入口 +├── app-1.0.0/ ← 版本目录 +│ ├── .current ← 当前版本标记 +│ ├── LanMountainDesktop.exe +│ └── ... +├── app-1.0.1/ ← 新版本 +│ ├── .partial ← 下载中标记 +│ └── ... +└── .launcher/ ← Launcher 数据 + ├── state/ ← OOBE 状态 + ├── update/incoming/ ← 更新缓存 + └── snapshots/ ← 更新快照 +``` + +**版本标记文件**: +- `.current` - 标记当前使用的版本 +- `.partial` - 标记下载未完成的版本(更新失败时自动清理) +- `.destroy` - 标记待删除的旧版本(下次启动时清理) + +--- + +## 8. 插件系统架构 + +### 8.1 插件生命周期 + +``` +插件包(.laapp) + │ + ▼ +发现阶段(DiscoverCandidates) + │ + ├── 扫描 PluginsDirectory + ├── 解析 plugin.json 清单 + └── 验证 API 版本兼容性 + │ + ▼ +加载阶段(PluginLoader.LoadFromPackage / LoadFromManifest) + │ + ├── 注册共享契约 + ├── 加载入口程序集 + ├── 调用 IPlugin.Initialize + └── 收集贡献点(设置页、组件、编辑器) + │ + ▼ +激活阶段 + │ + ├── 注册设置页到设置窗口 + ├── 注册组件到组件系统 + └── 注册编辑器到编辑器系统 + │ + ▼ +运行阶段 + │ + ├── 插件服务通过 DI 容器解析 + ├── 插件通过 IPluginContext 访问宿主功能 + └── 插件通过 IPC 与宿主通信 + │ + ▼ +卸载阶段 + │ + ├── 卸载插件程序集 + ├── 清理贡献点 + └── 释放资源 +``` + +### 8.2 插件运行时模式 + +| 模式 | 状态 | 说明 | +|------|------|------| +| `in-proc` | 当前默认 | 进程内加载,PluginLoadContext 提供程序集隔离 | +| `isolated-background` | 预留 | 后台逻辑移至独立工作进程,Host UI 变为薄 IPC 驱动壳 | +| `isolated-window` | 预留 | 插件 UI 离屏渲染,Host 嵌入平台窗口句柄 | + +### 8.3 插件贡献点 + +插件可以向宿主贡献以下内容: + +1. **设置页(Settings Sections)**:通过 `IPluginSettingsService` 注册自定义设置页 +2. **桌面组件(Desktop Components)**:通过组件贡献点注册可放置的桌面组件 +3. **组件编辑器(Component Editors)**:为组件提供自定义编辑器界面 +4. **公共服务(Public Services)**:通过 IPC 向外部提供公共服务 + +### 8.4 插件目录结构 + +``` +PluginsDirectory/ +├── PluginA/ +│ ├── plugin.json # 插件清单 +│ ├── PluginA.dll # 入口程序集 +│ └── ... # 其他资源 +├── PluginB.laapp # 打包的插件包 +└── ... +``` + +--- + +## 9. 数据流与交互模型 + +### 9.1 设置流 + +``` +Settings.Core(基础设置能力) + │ + ├── 宿主通过 SettingsFacade 读取和监听设置变化 + ├── 插件通过 IPluginSettingsService 访问设置 + └── 组件通过 IComponentSettingsAccessor 访问设置 +``` + +### 9.2 外观流 + +``` +Appearance(主题和圆角资源) + │ + ├── 宿主在 App.axaml.cs 中应用到资源字典 + ├── MaterialColorService 处理动态配色 + └── 主题变更通过事件通知所有订阅者 +``` + +### 9.3 组件流 + +``` +ComponentSystem(组件定义、注册、扩展接入) + │ + ├── 内置组件在 ComponentSystem/ 中定义 + ├── 插件通过贡献点注册扩展组件 + └── DesktopEditing/ 处理组件放置和布局 +``` + +### 9.4 插件流 + +``` +plugins/(宿主侧插件运行时) + │ + ├── .laapp 插件包的发现、安装、替换 + ├── 插件激活与共享契约装配 + └── 插件设置页注册到宿主设置窗口 +``` + +### 9.5 IPC 流 + +``` +Shared.IPC(统一 IPC 基础) + │ + ├── Host 公共服务 + ├── Launcher/OOBE 启动通知 + ├── 插件贡献的公共服务 + └── 外部集成(External IPC Public API) +``` + +--- + +## 10. 测试体系 + +### 10.1 测试项目 + +测试项目 `LanMountainDesktop.Tests/` 覆盖以下方面: + +| 测试类 | 覆盖内容 | +|--------|---------| +| `CornerRadiusScaleTests.cs` | 圆角和外观缩放 | +| `DesktopPlacementMathTests.cs` | 桌面布局数学计算 | +| `DesktopEditCommitMathTests.cs` | 桌面编辑提交计算 | +| `ComponentSettingsServiceTests.cs` | 组件设置服务 | +| `UiExceptionGuardTests.cs` | UI 异常保护 | +| `WhiteboardNotePersistenceServiceTests.cs` | 白板笔记持久化 | +| `MaterialColorIntegrationTests.cs` | 材质颜色集成 | +| `OobeStateServiceTests.cs` | OOBE 状态服务 | +| `PluginInstallerServiceTests.cs` | 插件安装服务 | +| `PluginUpgradeQueueServiceTests.cs` | 插件升级队列 | +| `LauncherFlowCoordinatorTests.cs` | 启动器流程协调 | +| `LauncherBackgroundServiceTests.cs` | 启动器后台服务 | +| `PluginIpcServerTests.cs` | 插件 IPC 服务端 | +| `PluginIpcClientTests.cs` | 插件 IPC 客户端 | +| `HostShutdownGateTests.cs` | 主机关闭门 | +| `SingleInstanceServiceTests.cs` | 单实例服务 | + +### 10.2 测试原则 + +- 涉及宿主行为、SDK 契约、布局计算或设置持久化的改动,应优先补对应测试 +- 优先扩展已有测试而不是新建无关测试入口 + +--- + +## 附录 A:快速参考 + +### A.1 关键文件速查 + +| 需求 | 优先查看文件 | +|------|-------------| +| 启动问题 | `LanMountainDesktop/Program.cs`, `LanMountainDesktop/App.axaml.cs` | +| Launcher 启动问题 | `LanMountainDesktop.Launcher/Program.cs`, `Services/LauncherFlowCoordinator.cs` | +| 版本管理问题 | `LanMountainDesktop.Launcher/Services/DeploymentLocator.cs` | +| 更新系统问题 | `LanMountainDesktop.Launcher/Services/UpdateEngineService.cs`, `UpdateCheckService.cs` | +| 设置窗口和设置页 | `LanMountainDesktop/Views/`, `ViewModels/`, `Services/Settings/` | +| 插件加载与安装 | `LanMountainDesktop/plugins/PluginRuntimeService.cs` | +| 组件元数据或放置规则 | `LanMountainDesktop/ComponentSystem/` | +| 主题、颜色、圆角 | `LanMountainDesktop/Theme/`, `Styles/`, `LanMountainDesktop.Appearance/` | +| 设置持久化 | `LanMountainDesktop.Settings.Core/`, `LanMountainDesktop/Services/Settings/SettingsService.cs` | +| SDK 接口调整 | `LanMountainDesktop.PluginSdk/`, `LanMountainDesktop.Shared.Contracts/` | +| 桌面壳层或生命周期 | `Program.cs`, `App.axaml.cs`, `LanMountainDesktop.DesktopHost/` | + +### A.2 文档权威来源 + +| 主题 | 权威文档 | +|------|---------| +| 产品定位 | `docs/PRODUCT.md` | +| 架构与模块职责 | `docs/ARCHITECTURE.md` | +| 运行、构建、测试、打包 | `docs/DEVELOPMENT.md` | +| 视觉规范 | `docs/VISUAL_SPEC.md` | +| 圆角规范 | `docs/CORNER_RADIUS_SPEC.md` | +| 生态边界 | `docs/ECOSYSTEM_BOUNDARIES.md` | +| SDK v5 迁移 | `docs/PLUGIN_SDK_V5_MIGRATION.md` | +| 代码地图 | `docs/ai/CODEBASE_MAP.md` | +| AI 协作入口 | `AGENTS.md` | +| Feature 规格 | `.trae/specs/` | + +--- + +*本文档基于 LanMountainDesktop 仓库代码和文档自动生成,如有更新请以仓库最新代码为准。* diff --git a/Directory.Packages.props b/Directory.Packages.props index aa56451..2678544 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,7 @@ + @@ -29,7 +30,7 @@ - + diff --git a/LanMountainDesktop.AirAppHost/AirApp.axaml b/LanMountainDesktop.AirAppHost/AirApp.axaml new file mode 100644 index 0000000..858ce71 --- /dev/null +++ b/LanMountainDesktop.AirAppHost/AirApp.axaml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + MiSans VF, avares://LanMountainDesktop.AirAppHost/Assets/Fonts#MiSans + #FFF7F9FC + #22000000 + #FF171A20 + #FF657080 + #FF2D73E5 + + + + + + + + + + + + + 18 + 10 + 8 + + diff --git a/LanMountainDesktop.AirAppHost/AirApp.axaml.cs b/LanMountainDesktop.AirAppHost/AirApp.axaml.cs new file mode 100644 index 0000000..fb98789 --- /dev/null +++ b/LanMountainDesktop.AirAppHost/AirApp.axaml.cs @@ -0,0 +1,24 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace LanMountainDesktop.AirAppHost; + +public sealed partial class AirApp : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var options = AirAppLaunchOptions.Parse(desktop.Args ?? []); + desktop.MainWindow = new AirAppWindow(options); + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs b/LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs new file mode 100644 index 0000000..7094b9d --- /dev/null +++ b/LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs @@ -0,0 +1,79 @@ +namespace LanMountainDesktop.AirAppHost; + +public sealed record AirAppLaunchOptions( + string AppId, + string SessionId, + string? SourceComponentId, + string? SourcePlacementId, + string? LauncherPipeName, + string? InstanceKey, + string? DataRoot) +{ + public const string WorldClockAppId = "world-clock"; + public const string WhiteboardAppId = "whiteboard"; + + public static AirAppLaunchOptions Parse(IReadOnlyList args) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var index = 0; index < args.Count; index++) + { + var arg = args[index]; + if (!arg.StartsWith("--", StringComparison.Ordinal)) + { + continue; + } + + var key = arg[2..].Trim(); + if (string.IsNullOrWhiteSpace(key)) + { + continue; + } + + var equalsIndex = key.IndexOf('='); + if (equalsIndex > 0) + { + var inlineValue = key[(equalsIndex + 1)..]; + key = key[..equalsIndex].Trim(); + if (!string.IsNullOrWhiteSpace(key)) + { + values[key] = inlineValue; + } + + continue; + } + + if (index + 1 < args.Count && !args[index + 1].StartsWith("--", StringComparison.Ordinal)) + { + values[key] = args[index + 1]; + index++; + } + else + { + values[key] = "true"; + } + } + + return new AirAppLaunchOptions( + GetValue(values, "app-id", WorldClockAppId), + GetValue(values, "session-id", Guid.NewGuid().ToString("N")), + GetOptionalValue(values, "source-component-id"), + GetOptionalValue(values, "source-placement-id"), + GetOptionalValue(values, "launcher-pipe"), + GetOptionalValue(values, "instance-key"), + GetOptionalValue(values, "data-root")); + } + + private static string GetValue(IReadOnlyDictionary values, string key, string fallback) + { + return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) + ? value.Trim() + : fallback; + } + + private static string? GetOptionalValue(IReadOnlyDictionary values, string key) + { + return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) + ? value.Trim() + : null; + } +} diff --git a/LanMountainDesktop.AirAppHost/AirAppWindow.axaml b/LanMountainDesktop.AirAppHost/AirAppWindow.axaml new file mode 100644 index 0000000..6cba450 --- /dev/null +++ b/LanMountainDesktop.AirAppHost/AirAppWindow.axaml @@ -0,0 +1,16 @@ + + + + + diff --git a/LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs b/LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs new file mode 100644 index 0000000..6e3af58 --- /dev/null +++ b/LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs @@ -0,0 +1,274 @@ +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using FluentAvalonia.UI.Windowing; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.Services; +using LanMountainDesktop.Shared.IPC; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; +using LanMountainDesktop.Views.Components; + +namespace LanMountainDesktop.AirAppHost; + +public sealed partial class AirAppWindow : FAAppWindow +{ + private readonly AirAppLaunchOptions _options; + private readonly AirAppWindowDescriptor _descriptor; + private WhiteboardWidget? _whiteboardWidget; + private string _instanceKey = string.Empty; + + public AirAppWindow() + : this(AirAppLaunchOptions.Parse([])) + { + } + + public AirAppWindow(AirAppLaunchOptions options) + { + _options = options; + _descriptor = AirAppWindowDescriptor.Create(options); + InitializeComponent(); + ConfigureWindow(); + } + + private void ConfigureWindow() + { + ApplyWindowDescriptor(_descriptor); + + if (string.Equals(_options.AppId, AirAppLaunchOptions.WorldClockAppId, StringComparison.OrdinalIgnoreCase)) + { + ContentHost.Content = new ClockAirAppView(_options); + return; + } + + if (string.Equals(_options.AppId, AirAppLaunchOptions.WhiteboardAppId, StringComparison.OrdinalIgnoreCase)) + { + ConfigureWhiteboardWindow(); + return; + } + + ContentHost.Content = new TextBlock + { + Text = $"Unsupported Air APP: {_options.AppId}", + Margin = new Avalonia.Thickness(18) + }; + } + + private void ApplyWindowDescriptor(AirAppWindowDescriptor descriptor) + { + Title = descriptor.Title; + Width = descriptor.Width; + Height = descriptor.Height; + MinWidth = descriptor.MinWidth; + MinHeight = descriptor.MinHeight; + ShowInTaskbar = descriptor.ShowInTaskbar; + CanResize = descriptor.CanResize; + ShowAsDialog = descriptor.ShowAsDialog; + WindowState = WindowState.Normal; + WindowRoot.Background = this.TryFindResource("AirAppWindowBackgroundBrush", out var brush) && brush is IBrush backgroundBrush + ? backgroundBrush + : Brushes.White; + ConfigureTitleBar(descriptor); + + switch (descriptor.ChromeMode) + { + case AirAppWindowChromeMode.Standard: + WindowDecorations = WindowDecorations.Full; + TitleBar.ExtendsContentIntoTitleBar = false; + break; + + case AirAppWindowChromeMode.Borderless: + WindowDecorations = WindowDecorations.None; + TitleBar.ExtendsContentIntoTitleBar = true; + break; + + case AirAppWindowChromeMode.FullScreen: + WindowDecorations = WindowDecorations.None; + TitleBar.ExtendsContentIntoTitleBar = true; + ShowAsDialog = false; + WindowState = WindowState.FullScreen; + break; + + case AirAppWindowChromeMode.Tool: + WindowDecorations = WindowDecorations.Full; + TitleBar.ExtendsContentIntoTitleBar = false; + ShowInTaskbar = false; + CanResize = false; + break; + + case AirAppWindowChromeMode.BackgroundOnly: + // Reserved for future background-only Air APPs. Keep a normal window for now + // so accidental launches remain visible and debuggable. + break; + } + } + + private void ConfigureTitleBar(AirAppWindowDescriptor descriptor) + { + TitleBar.Height = descriptor.ChromeMode == AirAppWindowChromeMode.Tool ? 36 : 40; + TitleBar.BackgroundColor = Colors.Transparent; + TitleBar.ForegroundColor = Color.FromRgb(32, 32, 32); + TitleBar.InactiveBackgroundColor = Colors.Transparent; + TitleBar.InactiveForegroundColor = Color.FromRgb(96, 96, 96); + TitleBar.ButtonBackgroundColor = Colors.Transparent; + TitleBar.ButtonHoverBackgroundColor = Color.FromArgb(23, 0, 0, 0); + TitleBar.ButtonPressedBackgroundColor = Color.FromArgb(52, 0, 0, 0); + TitleBar.ButtonInactiveBackgroundColor = Colors.Transparent; + TitleBar.ButtonInactiveForegroundColor = Colors.Gray; + } + + private void ConfigureWhiteboardWindow() + { + var componentId = string.IsNullOrWhiteSpace(_options.SourceComponentId) + ? BuiltInComponentIds.DesktopWhiteboard + : _options.SourceComponentId.Trim(); + var baseWidthCells = string.Equals(componentId, BuiltInComponentIds.DesktopBlackboardLandscape, StringComparison.OrdinalIgnoreCase) + ? 4 + : 2; + var widget = new WhiteboardWidget(baseWidthCells); + _whiteboardWidget = widget; + widget.SetComponentPlacementContext(componentId, _options.SourcePlacementId); + widget.SetSurfaceMode( + WhiteboardWidgetSurfaceMode.AirApp, + () => + { + widget.ForceSaveNote(); + Close(); + }); + + ContentHost.Content = widget; + AppLogger.Info( + "AirAppWindow", + $"Whiteboard content created. ComponentId='{componentId}'; PlacementId='{_options.SourcePlacementId ?? string.Empty}'."); + } + + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + _ = RegisterWithLauncherAsync(); + AppLogger.Info( + "AirAppWindow", + $"Opened. WindowRole=AirApp; AppId='{_options.AppId}'; ForegroundActivationRequested=True."); + Dispatcher.UIThread.Post(() => + { + Activate(); + }, DispatcherPriority.Background); + } + + protected override void OnClosing(WindowClosingEventArgs e) + { + SaveWhiteboard(); + base.OnClosing(e); + } + + protected override void OnClosed(EventArgs e) + { + SaveAndDisposeWhiteboard(); + _ = UnregisterWithLauncherAsync(); + base.OnClosed(e); + } + + private void SaveAndDisposeWhiteboard() + { + var widget = _whiteboardWidget; + if (widget is null) + { + return; + } + + SaveWhiteboard(); + if (ContentHost.Content == widget) + { + ContentHost.Content = null; + } + + widget.Dispose(); + _whiteboardWidget = null; + } + + private void SaveWhiteboard() + { + if (_whiteboardWidget is null) + { + return; + } + + try + { + _whiteboardWidget.ForceSaveNote(); + } + catch (Exception ex) + { + AppLogger.Warn("AirAppWindow", "Failed to force-save whiteboard before closing Air APP.", ex); + } + } + + private async Task RegisterWithLauncherAsync() + { + if (string.IsNullOrWhiteSpace(_options.LauncherPipeName)) + { + return; + } + + _instanceKey = ResolveInstanceKey(); + try + { + using var client = new LanMountainDesktopIpcClient(); + await client.ConnectAsync(_options.LauncherPipeName).ConfigureAwait(false); + var proxy = client.CreateProxy(); + _ = await proxy.RegisterAsync(new AirAppRegistrationRequest( + _instanceKey, + _options.AppId, + _options.SessionId, + Environment.ProcessId, + Title ?? "Air APP", + _options.SourceComponentId, + _options.SourcePlacementId)).ConfigureAwait(false); + } + catch + { + // Registration is best-effort; Launcher also tracks the process it started. + } + } + + private async Task UnregisterWithLauncherAsync() + { + if (string.IsNullOrWhiteSpace(_options.LauncherPipeName)) + { + return; + } + + var instanceKey = string.IsNullOrWhiteSpace(_instanceKey) ? ResolveInstanceKey() : _instanceKey; + try + { + using var client = new LanMountainDesktopIpcClient(); + await client.ConnectAsync(_options.LauncherPipeName).ConfigureAwait(false); + var proxy = client.CreateProxy(); + _ = await proxy.UnregisterAsync(instanceKey, Environment.ProcessId).ConfigureAwait(false); + } + catch + { + // Unregister is best-effort; Launcher prunes dead processes. + } + } + + private string ResolveInstanceKey() + { + if (!string.IsNullOrWhiteSpace(_options.InstanceKey)) + { + return _options.InstanceKey.Trim(); + } + + if (string.Equals(_options.AppId, AirAppLaunchOptions.WorldClockAppId, StringComparison.OrdinalIgnoreCase)) + { + return $"{AirAppLaunchOptions.WorldClockAppId}:clock-suite:global"; + } + + var componentId = string.IsNullOrWhiteSpace(_options.SourceComponentId) + ? "none" + : _options.SourceComponentId.Trim(); + var placementId = string.IsNullOrWhiteSpace(_options.SourcePlacementId) + ? "none" + : _options.SourcePlacementId.Trim(); + return $"{_options.AppId}:{componentId}:{placementId}"; + } +} diff --git a/LanMountainDesktop.AirAppHost/AirAppWindowChromeMode.cs b/LanMountainDesktop.AirAppHost/AirAppWindowChromeMode.cs new file mode 100644 index 0000000..1fa8a19 --- /dev/null +++ b/LanMountainDesktop.AirAppHost/AirAppWindowChromeMode.cs @@ -0,0 +1,10 @@ +namespace LanMountainDesktop.AirAppHost; + +public enum AirAppWindowChromeMode +{ + Standard, + Borderless, + FullScreen, + Tool, + BackgroundOnly +} diff --git a/LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs b/LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs new file mode 100644 index 0000000..4a5111d --- /dev/null +++ b/LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs @@ -0,0 +1,151 @@ +namespace LanMountainDesktop.AirAppHost; + +public sealed record AirAppWindowDescriptor( + string WindowTitle, + string TitleBarTitle, + string TitleBarSubtitle, + AirAppWindowChromeMode ChromeMode, + bool CanResize, + bool ShowInTaskbar, + bool ShowAsDialog, + double Width, + double Height, + double MinWidth, + double MinHeight) +{ + public string Title => WindowTitle; + + public string TitleText => TitleBarTitle; + + public string SubtitleText => TitleBarSubtitle; + + public static AirAppWindowDescriptor Create(AirAppLaunchOptions options) + { + if (string.Equals(options.AppId, AirAppLaunchOptions.WorldClockAppId, StringComparison.OrdinalIgnoreCase)) + { + return Standard( + "Clock - Air APP", + "Clock", + "Air APP", + width: 780, + height: 560, + minWidth: 680, + minHeight: 480, + canResize: true, + showAsDialog: false); + } + + if (string.Equals(options.AppId, AirAppLaunchOptions.WhiteboardAppId, StringComparison.OrdinalIgnoreCase)) + { + return FullScreen( + "Whiteboard - Air APP", + "Whiteboard", + "Air APP"); + } + + return Standard( + "Air APP", + "Air APP", + options.AppId); + } + + public static AirAppWindowDescriptor Standard( + string windowTitle, + string titleBarTitle, + string titleBarSubtitle, + double width = 520, + double height = 360, + double minWidth = 360, + double minHeight = 260, + bool canResize = true, + bool showAsDialog = false) + { + return new AirAppWindowDescriptor( + windowTitle, + titleBarTitle, + titleBarSubtitle, + AirAppWindowChromeMode.Standard, + CanResize: canResize, + ShowInTaskbar: true, + ShowAsDialog: showAsDialog, + width, + height, + minWidth, + minHeight); + } + + public static AirAppWindowDescriptor FullScreen( + string windowTitle, + string titleBarTitle, + string titleBarSubtitle) + { + return new AirAppWindowDescriptor( + windowTitle, + titleBarTitle, + titleBarSubtitle, + AirAppWindowChromeMode.FullScreen, + CanResize: false, + ShowInTaskbar: true, + ShowAsDialog: false, + Width: 1280, + Height: 720, + MinWidth: 360, + MinHeight: 260); + } + + public static AirAppWindowDescriptor Borderless( + string windowTitle, + double width = 520, + double height = 360) + { + return new AirAppWindowDescriptor( + windowTitle, + string.Empty, + string.Empty, + AirAppWindowChromeMode.Borderless, + CanResize: true, + ShowInTaskbar: true, + ShowAsDialog: false, + width, + height, + MinWidth: 240, + MinHeight: 180); + } + + public static AirAppWindowDescriptor Tool( + string windowTitle, + string titleBarTitle, + string titleBarSubtitle, + double width = 360, + double height = 260) + { + return new AirAppWindowDescriptor( + windowTitle, + titleBarTitle, + titleBarSubtitle, + AirAppWindowChromeMode.Tool, + CanResize: false, + ShowInTaskbar: false, + ShowAsDialog: true, + width, + height, + MinWidth: 240, + MinHeight: 180); + } + + public static AirAppWindowDescriptor BackgroundOnly(string appId) + { + return new AirAppWindowDescriptor( + $"{appId} - Air APP", + string.Empty, + string.Empty, + AirAppWindowChromeMode.BackgroundOnly, + CanResize: false, + ShowInTaskbar: false, + ShowAsDialog: false, + Width: 1, + Height: 1, + MinWidth: 1, + MinHeight: 1); + } +} diff --git a/LanMountainDesktop.AirAppHost/ClockAirAppView.axaml b/LanMountainDesktop.AirAppHost/ClockAirAppView.axaml new file mode 100644 index 0000000..a246b95 --- /dev/null +++ b/LanMountainDesktop.AirAppHost/ClockAirAppView.axaml @@ -0,0 +1,310 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + + + + + + + + + + + diff --git a/LanMountainDesktop.Launcher/Views/MultiInstancePromptWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/MultiInstancePromptWindow.axaml.cs new file mode 100644 index 0000000..56d3081 --- /dev/null +++ b/LanMountainDesktop.Launcher/Views/MultiInstancePromptWindow.axaml.cs @@ -0,0 +1,77 @@ +using Avalonia.Controls; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using LanMountainDesktop.Launcher.Resources; + +namespace LanMountainDesktop.Launcher.Views; + +public partial class MultiInstancePromptWindow : Window +{ + private readonly TaskCompletionSource _completionSource = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private string _details = Strings.MultiInstance_AlreadyRunning; + + public MultiInstancePromptWindow() + { + AvaloniaXamlLoader.Load(this); + Loaded += OnLoaded; + Closed += (_, _) => _completionSource.TrySetResult(MultiInstancePromptResult.Close); + } + + public Task WaitForChoiceAsync() => _completionSource.Task; + + public void SetDetails(int processId, string shellState) + { + _details = string.Format(Strings.MultiInstance_DetailsFormat, processId, shellState); + + if (this.FindControl("DetailsText") is { } detailsText) + { + detailsText.Text = _details; + } + } + + private void OnLoaded(object? sender, RoutedEventArgs e) + { + if (this.FindControl @@ -173,11 +175,11 @@ - - @@ -189,7 +191,7 @@ CornerRadius="{DynamicResource DesignCornerRadiusMd}" Padding="16"> - @@ -215,7 +217,7 @@ HorizontalAlignment="Center" VerticalAlignment="Center"/> - @@ -247,7 +249,7 @@ HorizontalAlignment="Center" VerticalAlignment="Center"/> - @@ -265,7 +267,7 @@ CornerRadius="{DynamicResource DesignCornerRadiusMd}" Padding="16"> - @@ -335,11 +337,11 @@ CornerRadius="{DynamicResource DesignCornerRadiusMd}" Padding="16"> - - @@ -359,11 +361,11 @@ VerticalAlignment="Center" Margin="0,0,12,0" /> - - @@ -384,11 +386,11 @@ VerticalAlignment="Center" Margin="0,0,12,0" /> - - @@ -409,11 +411,11 @@ VerticalAlignment="Center" Margin="0,0,12,0" /> - - @@ -431,10 +433,10 @@ Spacing="12" Margin="0,24,0,0"> + - + @@ -74,55 +119,73 @@ Width="1" HorizontalAlignment="Left" Background="{DynamicResource AdaptiveGlassPanelBorderBrush}" - Opacity="0.5"/> + Opacity="0.35"/> - + - - - + + + + + + Padding="12" + HorizontalAlignment="Center" + VerticalAlignment="Center"> + - - - + + + MinHeight="330"> @@ -134,7 +197,7 @@ + Text="选择一个分类以查看可添加组件。"/> diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs index bbca695..2ca6392 100644 --- a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs +++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs @@ -81,7 +81,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl _viewModel.Categories.Add(new ComponentLibraryCategoryViewModel( "all", L(languageCode, "component_category.all", "All"), - Symbol.Apps, + Icon.Apps, Array.Empty())); var usedCategories = _allDefinitions @@ -94,31 +94,21 @@ public partial class FusedDesktopComponentLibraryControl : UserControl var categoryComponents = _allDefinitions .Where(definition => string.Equals(definition.Category, category, StringComparison.OrdinalIgnoreCase)) .OrderBy(static definition => definition.DisplayName, StringComparer.OrdinalIgnoreCase) - .Select(CreateComponentItem) + .Select(definition => CreateComponentItem(definition, languageCode)) .ToArray(); + var categoryDefinitions = _allDefinitions + .Where(definition => string.Equals(definition.Category, category, StringComparison.OrdinalIgnoreCase)) + .ToList(); + _viewModel.Categories.Add(new ComponentLibraryCategoryViewModel( category, GetLocalizedCategoryTitle(languageCode, category), - ResolveCategoryIcon(category), + ComponentCategoryIconResolver.ResolveCategoryIcon(category, categoryDefinitions), categoryComponents)); } } - private static Symbol ResolveCategoryIcon(string categoryId) - { - if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) return Symbol.Clock; - if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase)) return Symbol.CalendarDate; - if (string.Equals(categoryId, "Weather", StringComparison.OrdinalIgnoreCase)) return Symbol.WeatherSunny; - if (string.Equals(categoryId, "Board", StringComparison.OrdinalIgnoreCase)) return Symbol.Edit; - if (string.Equals(categoryId, "Media", StringComparison.OrdinalIgnoreCase)) return Symbol.Play; - if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase)) return Symbol.Apps; - if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase)) return Symbol.Calculator; - if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) return Symbol.Hourglass; - if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase)) return Symbol.Folder; - return Symbol.Apps; - } - private string GetLocalizedCategoryTitle(string languageCode, string categoryId) { if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.clock", "Clock"); @@ -138,9 +128,11 @@ public partial class FusedDesktopComponentLibraryControl : UserControl return LocalizationService.GetString(languageCode, key, fallback); } - private static ComponentLibraryItemViewModel CreateComponentItem(DesktopComponentDefinition definition) + private ComponentLibraryItemViewModel CreateComponentItem(DesktopComponentDefinition definition, string languageCode) { - return new ComponentLibraryItemViewModel(definition.Id, definition.DisplayName); + var categoryTitle = GetLocalizedCategoryTitle(languageCode, definition.Category); + var description = $"{categoryTitle} - {Math.Max(1, definition.MinWidthCells)} x {Math.Max(1, definition.MinHeightCells)}"; + return new ComponentLibraryItemViewModel(definition.Id, definition.DisplayName, description); } private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e) @@ -174,7 +166,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl } _viewModel.SelectedComponent = selectedCategory.Components.FirstOrDefault(component => component.ComponentId == firstComponent.Id) - ?? CreateComponentItem(firstComponent); + ?? CreateComponentItem(firstComponent, _settingsFacade.Region.Get().LanguageCode); SetSelectedPreviewControl(CreateStaticPreviewControl(firstComponent)); } diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml index 0630960..3303c4c 100644 --- a/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml +++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml @@ -1,72 +1,62 @@ - - - - - - - - - + PointerPressed="OnWindowTitleBarPointerPressed"> + + + + + + + + - - diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs index 7261d48..d531b28 100644 --- a/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs +++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs @@ -1,50 +1,55 @@ using System; using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Input; using Avalonia.Interactivity; -using LanMountainDesktop.ComponentSystem; using LanMountainDesktop.Services; -using LanMountainDesktop.Services.Settings; -using Avalonia.Controls.ApplicationLifetimes; namespace LanMountainDesktop.Views; -/// -/// 融合桌面组件库窗口 - 专门用于添加组件到系统桌面(负一屏) -/// -/// 注意:此窗口只能添加组件到融合桌面,不能添加到阑山桌面 -/// public partial class FusedDesktopComponentLibraryWindow : Window { - private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate(); - private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate(); private TransparentOverlayWindow? _overlayWindow; - - // 与 TransparentOverlayWindow 保持一致的默认 cellSize - private const double DefaultCellSize = 100; - + public FusedDesktopComponentLibraryWindow() { InitializeComponent(); - + LibraryControl.AddComponentRequested += OnAddComponentRequested; - + KeyDown += OnWindowKeyDown; + var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow; mainWindow?.RegisterFusedLibraryWindow(this); } - - /// - /// 设置透明覆盖层窗口引用 - /// + + public bool PreserveEditModeOnClose { get; private set; } + public void SetOverlayWindow(TransparentOverlayWindow overlayWindow) { _overlayWindow = overlayWindow; } - - /// - /// 添加组件请求处理 - 将组件放置在屏幕(覆盖层画布)中央 - /// + + public void CenterInWorkArea(Window? referenceWindow = null) + { + var screen = referenceWindow is not null + ? Screens.ScreenFromWindow(referenceWindow) + : Screens.Primary; + screen ??= Screens.Primary; + if (screen is null) + { + return; + } + + var scaling = screen.Scaling; + var workArea = screen.WorkingArea; + var widthPx = (int)Math.Round(Math.Max(MinWidth, Width) * scaling); + var heightPx = (int)Math.Round(Math.Max(MinHeight, Height) * scaling); + var x = workArea.X + Math.Max(0, (workArea.Width - widthPx) / 2); + var y = workArea.Y + Math.Max(0, (workArea.Height - heightPx) / 2); + Position = new PixelPoint(x, y); + } + private void OnAddComponentRequested(object? sender, string componentId) { if (_overlayWindow is null) @@ -52,55 +57,17 @@ public partial class FusedDesktopComponentLibraryWindow : Window AppLogger.Warn("FusedDesktopLibrary", "Overlay window is not set."); return; } - - // 计算组件的像素尺寸 - var (componentWidth, componentHeight) = ResolveComponentSize(componentId); - - // 取覆盖层画布的中心点,减去组件半尺寸,使组件出现在屏幕正中央 - var overlayBounds = _overlayWindow.Bounds; - var centerX = overlayBounds.Width / 2.0 - componentWidth / 2.0; - var centerY = overlayBounds.Height / 2.0 - componentHeight / 2.0; - - // 边界保护:确保组件不超出屏幕边界 - centerX = Math.Max(0, Math.Min(centerX, overlayBounds.Width - componentWidth)); - centerY = Math.Max(0, Math.Min(centerY, overlayBounds.Height - componentHeight)); - - _overlayWindow.AddComponent(componentId, centerX, centerY, componentWidth, componentHeight); - - AppLogger.Info("FusedDesktopLibrary", - $"Added component '{componentId}' at center ({centerX:F0}, {centerY:F0}) size ({componentWidth}x{componentHeight})."); - - // 关闭窗口 + + _overlayWindow.AddComponentToCenter(componentId); + AppLogger.Info("FusedDesktopLibrary", $"Added component '{componentId}' at fused desktop grid center."); + + PreserveEditModeOnClose = true; Close(); } - - /// - /// 解析组件的默认像素尺寸(基于组件定义的 MinCells * DefaultCellSize) - /// - private (double Width, double Height) ResolveComponentSize(string componentId) - { - try - { - var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService; - var registry = DesktopComponentRegistryFactory.Create(pluginRuntimeService); - if (registry.TryGetDefinition(componentId, out var definition)) - { - var w = Math.Max(1, definition.MinWidthCells) * DefaultCellSize; - var h = Math.Max(1, definition.MinHeightCells) * DefaultCellSize; - return (w, h); - } - } - catch (Exception ex) - { - AppLogger.Warn("FusedDesktopLibrary", $"Failed to resolve component size for '{componentId}'.", ex); - } - - // 回退为 2×2 格子的默认尺寸 - return (DefaultCellSize * 2, DefaultCellSize * 2); - } - + private void OnCloseClick(object? sender, RoutedEventArgs e) { + PreserveEditModeOnClose = false; Close(); } @@ -111,10 +78,22 @@ public partial class FusedDesktopComponentLibraryWindow : Window BeginMoveDrag(e); } } - + + private void OnWindowKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + PreserveEditModeOnClose = false; + Close(); + } + } + protected override void OnClosed(EventArgs e) { + LibraryControl.AddComponentRequested -= OnAddComponentRequested; + KeyDown -= OnWindowKeyDown; base.OnClosed(e); + var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow; mainWindow?.UnregisterFusedLibraryWindow(this); } diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs index 3df48b1..9fd030b 100644 --- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs @@ -58,7 +58,7 @@ public partial class MainWindow : Window private sealed record ComponentLibraryCategory( string Id, - Symbol Icon, + Icon Icon, string Title, IReadOnlyList Components); @@ -553,6 +553,11 @@ public partial class MainWindow : Window _taskbarLayoutMode = string.IsNullOrWhiteSpace(snapshot.TaskbarLayoutMode) ? TaskbarLayoutBottomFullRowMacStyle : snapshot.TaskbarLayoutMode; + _backToWindowsButtonDisplayMode = NormalizeBackToWindowsButtonDisplayMode(snapshot.BackToWindowsButtonDisplayMode); + _backToWindowsIconSource = NormalizeBackToWindowsIconSource(snapshot.BackToWindowsIconSource); + _backToWindowsFluentIconName = NormalizeBackToWindowsFluentIcon(snapshot.BackToWindowsFluentIconName).ToString(); + _backToWindowsIconText = NormalizeBackToWindowsIconText(snapshot.BackToWindowsIconText); + RefreshBackToWindowsButtonPresentation(); _clockDisplayFormat = snapshot.ClockDisplayFormat == "HourMinute" ? ClockDisplayFormat.HourMinute @@ -2386,9 +2391,10 @@ public partial class MainWindow : Window new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2)); } - if (string.Equals(componentId, BuiltInComponentIds.DesktopWorldClock, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(componentId, BuiltInComponentIds.DesktopWorldClock, StringComparison.OrdinalIgnoreCase) || + string.Equals(componentId, BuiltInComponentIds.DesktopStandbyDigitalClock, StringComparison.OrdinalIgnoreCase)) { - // Keep world clock widget at 2:1 ratio: 4x2, 6x3, 8x4... + // Keep world clock / StandBy digital clock widget at 2:1 ratio: 4x2, 6x3, 8x4... return SnapSpanToScaleRules( span, new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2)); @@ -2868,7 +2874,12 @@ public partial class MainWindow : Window private void OnDesktopComponentHostPointerPressed(object? sender, PointerPressedEventArgs e) { - if (!_isComponentLibraryOpen || HasActiveDesktopEditSession) + if (!_isComponentLibraryOpen) + { + return; + } + + if (HasActiveDesktopEditSession) { return; } @@ -3385,9 +3396,9 @@ public partial class MainWindow : Window var row = new RowDefinition(GridLength.Auto); ComponentLibraryCategoryPagesContainer.RowDefinitions.Add(row); - var icon = new SymbolIcon + var icon = new FluentIcon { - Symbol = category.Icon, + Icon = category.Icon, IconVariant = IconVariant.Regular, FontSize = 18, VerticalAlignment = VerticalAlignment.Center @@ -3456,62 +3467,14 @@ public partial class MainWindow : Window return categories .Select(category => new ComponentLibraryCategory( category.Id, - ResolveComponentLibraryCategoryIcon(category.Id), + ComponentCategoryIconResolver.ResolveCategoryIcon( + category.Id, + _componentRegistry.GetAll().Where(d => string.Equals(d.Category, category.Id, StringComparison.OrdinalIgnoreCase))), GetLocalizedComponentLibraryCategoryTitle(category.Id), category.Components)) .ToList(); } - private Symbol ResolveComponentLibraryCategoryIcon(string categoryId) - { - if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Clock; - } - - if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.CalendarDate; - } - - if (string.Equals(categoryId, "Weather", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.WeatherSunny; - } - - if (string.Equals(categoryId, "Board", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Edit; - } - - if (string.Equals(categoryId, "Media", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Play; - } - - if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Apps; - } - - if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Calculator; - } - - if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Hourglass; - } - - if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase)) - { - return Symbol.Folder; - } - - return Symbol.Apps; - } - private string GetLocalizedComponentLibraryCategoryTitle(string categoryId) { if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) diff --git a/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs b/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs index 2c3edfe..3aec7d2 100644 --- a/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs +++ b/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs @@ -15,6 +15,7 @@ using FluentAvalonia.UI.Controls; using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; +using LanMountainDesktop.Services.Update; using LanMountainDesktop.Theme; using LanMountainDesktop.Views.Components; @@ -63,6 +64,23 @@ public partial class MainWindow : Window if (e.Scope == SettingsScope.App && e.ChangedKeys is { Count: > 0 }) { var changedKeys = e.ChangedKeys.ToArray(); + if (changedKeys.Any(key => + string.Equals(key, nameof(AppSettingsSnapshot.BackToWindowsButtonDisplayMode), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, nameof(AppSettingsSnapshot.BackToWindowsIconSource), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, nameof(AppSettingsSnapshot.BackToWindowsFluentIconName), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, nameof(AppSettingsSnapshot.BackToWindowsIconText), StringComparison.OrdinalIgnoreCase))) + { + Dispatcher.UIThread.Post(() => + { + var snapshot = _settingsService.LoadSnapshot(SettingsScope.App); + _backToWindowsButtonDisplayMode = NormalizeBackToWindowsButtonDisplayMode(snapshot.BackToWindowsButtonDisplayMode); + _backToWindowsIconSource = NormalizeBackToWindowsIconSource(snapshot.BackToWindowsIconSource); + _backToWindowsFluentIconName = NormalizeBackToWindowsFluentIcon(snapshot.BackToWindowsFluentIconName).ToString(); + _backToWindowsIconText = NormalizeBackToWindowsIconText(snapshot.BackToWindowsIconText); + RefreshBackToWindowsButtonPresentation(); + }, DispatcherPriority.Normal); + } + if (changedKeys.All(key => string.Equals(key, nameof(AppSettingsSnapshot.ThemeColorMode), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.SystemMaterialMode), StringComparison.OrdinalIgnoreCase) || @@ -78,9 +96,15 @@ public partial class MainWindow : Window string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadSource), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.UseGhProxyMirror), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadThreads), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, nameof(AppSettingsSnapshot.ForceUpdateReinstall), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.EnableThreeFingerSwipe), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.EnableFadeTransition), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.ShowInTaskbar), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, nameof(AppSettingsSnapshot.MultiInstanceLaunchBehavior), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, nameof(AppSettingsSnapshot.BackToWindowsButtonDisplayMode), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, nameof(AppSettingsSnapshot.BackToWindowsIconSource), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, nameof(AppSettingsSnapshot.BackToWindowsFluentIconName), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, nameof(AppSettingsSnapshot.BackToWindowsIconText), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.EnableSlideTransition), StringComparison.OrdinalIgnoreCase))) { return; @@ -140,12 +164,15 @@ public partial class MainWindow : Window private void ApplyLocalization() { Title = L("app.title", "LanMountainDesktop"); - var platformName = OperatingSystem.IsWindows() ? "Windows" - : OperatingSystem.IsMacOS() ? "macOS" - : "Linux"; + var platformName = OperatingSystem.IsWindows() + ? L("platform.windows", "Windows") + : OperatingSystem.IsMacOS() + ? L("platform.macos", "macOS") + : L("platform.linux", "Linux"); BackToWindowsTextBlock.Text = Lf("button.back_to_platform", "Back to {0}", platformName); ToolTip.SetTip(BackToWindowsButton, Lf("tooltip.back_to_platform", "Back to {0}", platformName)); ComponentLibraryTitleTextBlock.Text = L("component_library.title", "Widgets"); + ComponentLibraryEmptyTextBlock.Text = L("component_library.components_none", "No components."); LauncherTitleTextBlock.Text = L("launcher.title", "App Launcher"); LauncherSubtitleTextBlock.Text = OperatingSystem.IsLinux() ? L("launcher.subtitle_linux", "Displays installed apps discovered from Linux desktop entries.") @@ -177,10 +204,15 @@ public partial class MainWindow : Window _weatherLongitude = snapshot.WeatherLongitude; _weatherAutoRefreshLocation = snapshot.WeatherAutoRefreshLocation; _weatherExcludedAlertsRaw = snapshot.WeatherExcludedAlerts ?? string.Empty; - _weatherIconPackId = string.IsNullOrWhiteSpace(snapshot.WeatherIconPackId) ? "HyperOS3" : snapshot.WeatherIconPackId; + _weatherIconPackId = NormalizeWeatherIconPackId(snapshot.WeatherIconPackId); _weatherNoTlsRequests = snapshot.WeatherNoTlsRequests; } + private static string NormalizeWeatherIconPackId(string? iconPackId) + { + return WeatherVisualStyleCatalog.Normalize(iconPackId); + } + private void InitializeAutoStartWithWindowsSetting(AppSettingsSnapshot snapshot) { _autoStartWithWindows = snapshot.AutoStartWithWindows; @@ -474,28 +506,14 @@ public partial class MainWindow : Window private void TriggerAutoUpdateCheckIfEnabled() { - var versionText = _settingsFacade.ApplicationInfo.GetAppVersionText(); - if (!Version.TryParse(versionText, out var currentVersion)) - { - currentVersion = new Version(0, 0, 0); - } - - var major = Math.Max(0, currentVersion.Major); - var minor = Math.Max(0, currentVersion.Minor); - var build = Math.Max(0, currentVersion.Build >= 0 ? currentVersion.Build : 0); - var revision = Math.Max(0, currentVersion.Revision >= 0 ? currentVersion.Revision : 0); - var normalizedVersion = revision > 0 - ? new Version(major, minor, build, revision) - : new Version(major, minor, build); - DispatcherTimer.RunOnce( async () => { try { - await HostUpdateWorkflowServiceProvider + await HostUpdateOrchestratorProvider .GetOrCreate() - .AutoCheckIfEnabledAsync(normalizedVersion); + .AutoCheckIfEnabledAsync(default); } catch (Exception ex) { @@ -632,6 +650,8 @@ public partial class MainWindow : Window ThemeColorMode = latestThemeState.ThemeColorMode, SystemMaterialMode = latestThemeState.SystemMaterialMode, SelectedWallpaperSeed = latestThemeState.SelectedWallpaperSeed, + ThemeWallpaperColorSource = latestThemeState.ThemeWallpaperColorSource, + UseNativeWallpaperChangeEvents = latestThemeState.UseNativeWallpaperChangeEvents, UseSystemChrome = latestThemeState.UseSystemChrome, CornerRadiusStyle = latestThemeState.CornerRadiusStyle, WallpaperPath = latestWallpaperState.WallpaperPath, @@ -662,6 +682,7 @@ public partial class MainWindow : Window UpdateMode = latestUpdateState.UpdateMode, UpdateDownloadSource = latestUpdateState.UpdateDownloadSource, UpdateDownloadThreads = latestUpdateState.UpdateDownloadThreads, + ForceUpdateReinstall = latestUpdateState.ForceUpdateReinstall, UseGhProxyMirror = latestUpdateState.UseGhProxyMirror, PendingUpdateInstallerPath = latestUpdateState.PendingUpdateInstallerPath, PendingUpdateVersion = latestUpdateState.PendingUpdateVersion, @@ -671,6 +692,10 @@ public partial class MainWindow : Window PinnedTaskbarActions = [.. _pinnedTaskbarActions.Select(v => v.ToString())], EnableDynamicTaskbarActions = _enableDynamicTaskbarActions, TaskbarLayoutMode = _taskbarLayoutMode, + BackToWindowsButtonDisplayMode = existingSnapshot.BackToWindowsButtonDisplayMode, + BackToWindowsIconSource = existingSnapshot.BackToWindowsIconSource, + BackToWindowsFluentIconName = existingSnapshot.BackToWindowsFluentIconName, + BackToWindowsIconText = existingSnapshot.BackToWindowsIconText, ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond", StatusBarClockTransparentBackground = _statusBarClockTransparentBackground, ClockPosition = _clockPosition, @@ -695,6 +720,7 @@ public partial class MainWindow : Window EnableFadeTransition = existingSnapshot.EnableFadeTransition, EnableSlideTransition = existingSnapshot.EnableSlideTransition, ShowInTaskbar = existingSnapshot.ShowInTaskbar, + MultiInstanceLaunchBehavior = existingSnapshot.MultiInstanceLaunchBehavior, EnableFusedDesktop = existingSnapshot.EnableFusedDesktop, DisabledPluginIds = existingSnapshot.DisabledPluginIds, StudyFrameMs = existingSnapshot.StudyFrameMs, diff --git a/LanMountainDesktop/Views/MainWindow.SingleInstanceNotice.cs b/LanMountainDesktop/Views/MainWindow.SingleInstanceNotice.cs deleted file mode 100644 index 4c5eb1b..0000000 --- a/LanMountainDesktop/Views/MainWindow.SingleInstanceNotice.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Threading.Tasks; -using Avalonia.Controls; -using Avalonia.Threading; -using FluentAvalonia.UI.Controls; -using LanMountainDesktop.Services; - -namespace LanMountainDesktop.Views; - -public partial class MainWindow : Window -{ - private bool _isSingleInstancePromptVisible; - - internal void ShowSingleInstanceNotice() - { - void ShowPrompt() - { - UiExceptionGuard.FireAndForgetGuarded( - ShowSingleInstanceNoticeCoreAsync, - "MainWindow.ShowSingleInstanceNotice"); - } - - if (Dispatcher.UIThread.CheckAccess()) - { - ShowPrompt(); - return; - } - - Dispatcher.UIThread.Post(ShowPrompt, DispatcherPriority.Send); - } - - private async Task ShowSingleInstanceNoticeCoreAsync() - { - if (_isSingleInstancePromptVisible) - { - return; - } - - _isSingleInstancePromptVisible = true; - - try - { - var dialog = new FAContentDialog - { - Title = L("single_instance.notice.title", "Already running"), - Content = L( - "single_instance.notice.description", - "LanMountainDesktop is already running. The existing window will stay active, so no new instance was started."), - PrimaryButtonText = L("single_instance.notice.button", "OK"), - DefaultButton = FAContentDialogButton.Primary - }; - - await dialog.ShowAsync(this); - } - finally - { - _isSingleInstancePromptVisible = false; - } - } -} diff --git a/LanMountainDesktop/Views/MainWindow.axaml b/LanMountainDesktop/Views/MainWindow.axaml index 165f19a..a30700c 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml +++ b/LanMountainDesktop/Views/MainWindow.axaml @@ -334,14 +334,19 @@ BorderThickness="0" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" Click="OnMinimizeClick"> - - + + + diff --git a/LanMountainDesktop/Views/MainWindow.axaml.cs b/LanMountainDesktop/Views/MainWindow.axaml.cs index 902e122..630f9ef 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml.cs +++ b/LanMountainDesktop/Views/MainWindow.axaml.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; using System.Runtime.InteropServices; @@ -18,6 +19,7 @@ using Avalonia.Platform.Storage; using Avalonia.Styling; using Avalonia.Threading; using Avalonia.VisualTree; +using FluentIcons.Avalonia; using FluentAvalonia.Styling; using LanMountainDesktop.ComponentSystem; using LanMountainDesktop.Models; @@ -27,6 +29,8 @@ using LanMountainDesktop.Services.Settings; using LanMountainDesktop.Shared.Contracts.Launcher; using LanMountainDesktop.Theme; using LanMountainDesktop.Views.Components; +using FluentIconKind = FluentIcons.Common.Icon; +using FluentIconVariant = FluentIcons.Common.IconVariant; namespace LanMountainDesktop.Views; @@ -62,6 +66,13 @@ public partial class MainWindow : Window private static readonly int SettingsTransitionDurationMs = (int)FluttermotionToken.Page.TotalMilliseconds; private const double LightBackgroundLuminanceThreshold = 0.57; private const string TaskbarLayoutBottomFullRowMacStyle = "BottomFullRowMacStyle"; + private const string BackToWindowsButtonDisplayModeIconAndText = "IconAndText"; + private const string BackToWindowsButtonDisplayModeIconOnly = "IconOnly"; + private const string BackToWindowsButtonDisplayModeTextOnly = "TextOnly"; + private const string BackToWindowsIconSourceFluentIcon = "FluentIcon"; + private const string BackToWindowsIconSourceText = "Text"; + private const string DefaultBackToWindowsFluentIconName = "Circle"; + private const string DefaultBackToWindowsIconText = "○"; private static readonly HashSet SupportedImageExtensions = new(StringComparer.OrdinalIgnoreCase) { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp" @@ -95,6 +106,7 @@ public partial class MainWindow : Window private readonly IComponentLibraryService _componentLibraryService; private readonly IComponentEditorWindowService _componentEditorWindowService; private readonly IEmbeddedComponentLibraryService _componentLibraryWindowService = new EmbeddedComponentLibraryService(); + private readonly IAirAppLauncherService _airAppLauncherService = AirAppLauncherServiceProvider.GetOrCreate(); private ComponentLibraryWindow? _detachedComponentLibraryWindow; private readonly FluentAvaloniaTheme? _fluentAvaloniaTheme; private readonly HashSet _topStatusComponentIds = new(StringComparer.OrdinalIgnoreCase); @@ -159,6 +171,10 @@ public partial class MainWindow : Window private double _statusBarShadowOpacity = 0.3; private int _desktopEdgeInsetPercent = DefaultEdgeInsetPercent; private string _taskbarLayoutMode = TaskbarLayoutBottomFullRowMacStyle; + private string _backToWindowsButtonDisplayMode = BackToWindowsButtonDisplayModeIconAndText; + private string _backToWindowsIconSource = BackToWindowsIconSourceFluentIcon; + private string _backToWindowsFluentIconName = DefaultBackToWindowsFluentIconName; + private string _backToWindowsIconText = DefaultBackToWindowsIconText; private string _languageCode = "zh-CN"; private WeatherLocationMode _weatherLocationMode = WeatherLocationMode.CitySearch; private string _weatherLocationKey = string.Empty; @@ -167,7 +183,7 @@ public partial class MainWindow : Window private double _weatherLongitude = 116.4074; private bool _weatherAutoRefreshLocation; private string _weatherExcludedAlertsRaw = string.Empty; - private string _weatherIconPackId = "HyperOS3"; + private string _weatherIconPackId = WeatherVisualStyleId.Default; private bool _weatherNoTlsRequests; private bool _autoStartWithWindows; private bool _suppressAutoStartToggleEvents; @@ -746,16 +762,18 @@ public partial class MainWindow : Window NetworkSpeedWidgetRight.Margin = new Thickness(0); NetworkSpeedWidgetRight.ApplyCellSize(cellSize); - var buttonMinWidth = Math.Clamp(taskbarCellHeight * 2.35, 100, 340); + var buttonMinWidth = GetBackToWindowsButtonMinWidth(taskbarCellHeight); BackToWindowsButton.Margin = new Thickness(0); BackToWindowsButton.Padding = taskbarButtonPadding; BackToWindowsButton.FontSize = taskbarTextSize; BackToWindowsButton.MinHeight = taskbarCellHeight; BackToWindowsButton.MinWidth = buttonMinWidth; - BackToWindowsIcon.FontSize = taskbarIconSize; + BackToWindowsButton.Width = double.NaN; + BackToWindowsButton.Height = double.NaN; + ApplyBackToWindowsIconCircleSize(taskbarCellHeight); BackToWindowsTextBlock.FontSize = taskbarTextSize; - SetButtonContentSpacing(BackToWindowsButton, buttonContentSpacing); + RefreshBackToWindowsButtonPresentation(buttonContentSpacing); TaskbarProfileButton.Margin = new Thickness(0); TaskbarProfileButton.Padding = new Thickness(0); @@ -813,6 +831,156 @@ public partial class MainWindow : Window } } + private double GetBackToWindowsButtonMinWidth(double taskbarCellHeight) + { + return _backToWindowsButtonDisplayMode switch + { + BackToWindowsButtonDisplayModeIconOnly => taskbarCellHeight, + BackToWindowsButtonDisplayModeTextOnly => Math.Clamp(taskbarCellHeight * 1.8, 72, 260), + _ => Math.Clamp(taskbarCellHeight * 2.35, 100, 340) + }; + } + + private double GetBackToWindowsTaskbarCellHeight() + { + if (_currentDesktopCellSize > 0) + { + return Math.Clamp(_currentDesktopCellSize * 0.76, 36, 76); + } + + if (BackToWindowsButton.MinHeight > 0 && !double.IsNaN(BackToWindowsButton.MinHeight)) + { + return Math.Clamp(BackToWindowsButton.MinHeight, 36, 76); + } + + return 48; + } + + private double GetBackToWindowsContentSpacing(double taskbarCellHeight) + { + return Math.Clamp(taskbarCellHeight * 0.20, 6, 14); + } + + private void ApplyBackToWindowsIconCircleSize(double taskbarCellHeight) + { + var hitBoxSize = Math.Clamp(taskbarCellHeight * 0.62, 24, 44); + var iconSize = Math.Clamp(taskbarCellHeight * 0.32, 14, 24); + + BackToWindowsIconCircle.Width = hitBoxSize; + BackToWindowsIconCircle.Height = hitBoxSize; + BackToWindowsIconCircle.CornerRadius = new CornerRadius(hitBoxSize / 2d); + BackToWindowsIconHost.Width = hitBoxSize; + BackToWindowsIconHost.Height = hitBoxSize; + + if (BackToWindowsIconHost.Content is FluentIcon fluentIcon) + { + fluentIcon.FontSize = iconSize; + fluentIcon.Width = iconSize; + fluentIcon.Height = iconSize; + } + else if (BackToWindowsIconHost.Content is TextBlock textBlock) + { + textBlock.FontSize = Math.Clamp(taskbarCellHeight * 0.30, 12, 22); + } + } + + private static string NormalizeBackToWindowsIconSource(string? value) + { + return string.Equals(value, BackToWindowsIconSourceText, StringComparison.OrdinalIgnoreCase) + ? BackToWindowsIconSourceText + : BackToWindowsIconSourceFluentIcon; + } + + private static FluentIconKind NormalizeBackToWindowsFluentIcon(string? value) + { + return Enum.TryParse(value, ignoreCase: true, out var icon) + ? icon + : FluentIconKind.Circle; + } + + private static string NormalizeBackToWindowsIconText(string? value) + { + var normalized = string.IsNullOrWhiteSpace(value) + ? DefaultBackToWindowsIconText + : value.Trim(); + + var enumerator = StringInfo.GetTextElementEnumerator(normalized); + var builder = new System.Text.StringBuilder(); + var count = 0; + while (enumerator.MoveNext() && count < 4) + { + builder.Append(enumerator.GetTextElement()); + count++; + } + + return builder.Length > 0 ? builder.ToString() : DefaultBackToWindowsIconText; + } + + private void RefreshBackToWindowsIconContent(double taskbarCellHeight) + { + _backToWindowsIconSource = NormalizeBackToWindowsIconSource(_backToWindowsIconSource); + if (_backToWindowsIconSource == BackToWindowsIconSourceText) + { + BackToWindowsIconHost.Content = new TextBlock + { + Text = NormalizeBackToWindowsIconText(_backToWindowsIconText), + Foreground = GetThemeBrush("AdaptiveTextPrimaryBrush"), + FontWeight = FontWeight.SemiBold, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + TextAlignment = TextAlignment.Center + }; + ApplyBackToWindowsIconCircleSize(taskbarCellHeight); + return; + } + + BackToWindowsIconHost.Content = new FluentIcon + { + Icon = NormalizeBackToWindowsFluentIcon(_backToWindowsFluentIconName), + IconVariant = FluentIconVariant.Regular, + Foreground = GetThemeBrush("AdaptiveTextPrimaryBrush"), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + ApplyBackToWindowsIconCircleSize(taskbarCellHeight); + } + + private static string NormalizeBackToWindowsButtonDisplayMode(string? value) + { + return value switch + { + _ when string.Equals(value, BackToWindowsButtonDisplayModeIconOnly, StringComparison.OrdinalIgnoreCase) => + BackToWindowsButtonDisplayModeIconOnly, + _ when string.Equals(value, BackToWindowsButtonDisplayModeTextOnly, StringComparison.OrdinalIgnoreCase) => + BackToWindowsButtonDisplayModeTextOnly, + _ => BackToWindowsButtonDisplayModeIconAndText + }; + } + + private void RefreshBackToWindowsButtonPresentation(double? contentSpacing = null) + { + _backToWindowsButtonDisplayMode = NormalizeBackToWindowsButtonDisplayMode(_backToWindowsButtonDisplayMode); + var taskbarCellHeight = GetBackToWindowsTaskbarCellHeight(); + BackToWindowsButton.MinWidth = GetBackToWindowsButtonMinWidth(taskbarCellHeight); + RefreshBackToWindowsIconContent(taskbarCellHeight); + + var showIcon = _backToWindowsButtonDisplayMode is not BackToWindowsButtonDisplayModeTextOnly; + var showText = _backToWindowsButtonDisplayMode is not BackToWindowsButtonDisplayModeIconOnly; + + BackToWindowsIconCircle.IsVisible = showIcon; + BackToWindowsTextBlock.IsVisible = showText; + + if (BackToWindowsContentPanel is not null) + { + BackToWindowsContentPanel.Spacing = showIcon && showText + ? contentSpacing ?? GetBackToWindowsContentSpacing(taskbarCellHeight) + : 0; + } + + BackToWindowsButton.InvalidateMeasure(); + BackToWindowsButton.InvalidateArrange(); + } + private void UpdateComponentLibraryLayout(double cellSize) { if (ComponentLibraryWindow is null) diff --git a/LanMountainDesktop/Views/NotificationDialogWindow.axaml.cs b/LanMountainDesktop/Views/NotificationDialogWindow.axaml.cs index 62471ea..fa86bd8 100644 --- a/LanMountainDesktop/Views/NotificationDialogWindow.axaml.cs +++ b/LanMountainDesktop/Views/NotificationDialogWindow.axaml.cs @@ -9,6 +9,7 @@ using Avalonia.Styling; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using FluentIcons.Avalonia; +using LanMountainDesktop.Models; using LanMountainDesktop.Services; namespace LanMountainDesktop.Views; @@ -27,25 +28,32 @@ public partial class NotificationDialogWindow : Window } public void Initialize(NotificationContent content, IAppearanceThemeService? themeService = null) + { + Initialize(content, themeService is IMaterialColorService materialColorService + ? materialColorService.GetMaterialColorSnapshot() + : themeService is null + ? null + : HostMaterialColorProvider.GetOrCreate().GetMaterialColorSnapshot()); + } + + public void Initialize(NotificationContent content, MaterialColorSnapshot? materialColorSnapshot) { _viewModel = new NotificationDialogViewModel(content, this); DataContext = _viewModel; CompletionSource = new TaskCompletionSource(); - bool isNightMode = false; - if (themeService is not null) + if (materialColorSnapshot is not null) { - var snapshot = themeService.GetCurrent(); - isNightMode = snapshot.IsNightMode; - RequestedThemeVariant = isNightMode ? ThemeVariant.Dark : ThemeVariant.Light; + RequestedThemeVariant = materialColorSnapshot.IsNightMode ? ThemeVariant.Dark : ThemeVariant.Light; } - if (DialogCard is not null) + if (DialogCard is not null && materialColorSnapshot is not null) { - DialogCard.Background = isNightMode - ? new SolidColorBrush(Color.Parse("#FF2D2D2D")) - : new SolidColorBrush(Color.Parse("#FFF8F9FA")); + var cardSurface = GetDialogSurface(materialColorSnapshot); + DialogCard.Background = new SolidColorBrush(cardSurface.BackgroundColor); + DialogCard.BorderBrush = new SolidColorBrush(cardSurface.BorderColor); + DialogCard.BorderThickness = new Thickness(1); } if (!HasButtons(content) && content.Duration.HasValue) @@ -59,6 +67,20 @@ public partial class NotificationDialogWindow : Window } } + private static MaterialSurfaceSnapshot GetDialogSurface(MaterialColorSnapshot materialColorSnapshot) + { + return materialColorSnapshot.Surfaces.TryGetValue(MaterialSurfaceRole.OverlayPanel, out var overlaySurface) + ? overlaySurface + : materialColorSnapshot.Surfaces.TryGetValue(MaterialSurfaceRole.WindowBackground, out var windowSurface) + ? windowSurface + : new MaterialSurfaceSnapshot( + MaterialSurfaceRole.WindowBackground, + Color.Parse("#FFF8F9FA"), + Color.Parse("#22000000"), + 0, + 1); + } + private static bool HasButtons(NotificationContent content) { return !string.IsNullOrEmpty(content.PrimaryButtonText) || diff --git a/LanMountainDesktop/Views/NotificationWindow.axaml.cs b/LanMountainDesktop/Views/NotificationWindow.axaml.cs index 2e0ec64..ab8badd 100644 --- a/LanMountainDesktop/Views/NotificationWindow.axaml.cs +++ b/LanMountainDesktop/Views/NotificationWindow.axaml.cs @@ -8,6 +8,7 @@ using Avalonia.Input; using Avalonia.Media; using Avalonia.Styling; using Avalonia.Threading; +using LanMountainDesktop.Models; using LanMountainDesktop.Services; using LanMountainDesktop.Theme; using LanMountainDesktop.ViewModels; @@ -31,42 +32,58 @@ public partial class NotificationWindow : Window } public void Initialize(NotificationViewModel viewModel, IAppearanceThemeService? themeService = null) + { + Initialize(viewModel, themeService is IMaterialColorService materialColorService + ? materialColorService.GetMaterialColorSnapshot() + : themeService is null + ? null + : HostMaterialColorProvider.GetOrCreate().GetMaterialColorSnapshot()); + } + + public void Initialize(NotificationViewModel viewModel, MaterialColorSnapshot? materialColorSnapshot) { _viewModel = viewModel; DataContext = viewModel; - + _remainingDuration = viewModel.Duration; - - ApplyTheme(themeService); + + ApplyTheme(materialColorSnapshot); ApplySeverityColor(); } - private void ApplyTheme(IAppearanceThemeService? themeService) + public void ApplyMaterialSnapshot(MaterialColorSnapshot materialColorSnapshot) { - if (themeService is null) return; + ApplyTheme(materialColorSnapshot); + ApplySeverityColor(); + } - var snapshot = themeService.GetCurrent(); - RequestedThemeVariant = snapshot.IsNightMode ? ThemeVariant.Dark : ThemeVariant.Light; + private void ApplyTheme(MaterialColorSnapshot? materialColorSnapshot) + { + // Notification windows must always stay transparent, regardless of whether + // we have a live material snapshot. + Background = Brushes.Transparent; + TransparencyLevelHint = [WindowTransparencyLevel.Transparent]; + + if (materialColorSnapshot is null) return; + + RequestedThemeVariant = materialColorSnapshot.IsNightMode ? ThemeVariant.Dark : ThemeVariant.Light; // Apply glass effect resources directly to window resources // This ensures the notification card has proper background/border colors - var context = CreateThemeContext(snapshot); + var context = CreateThemeContext(materialColorSnapshot); GlassEffectService.ApplyGlassResources(Resources, context); // IMPORTANT: Do NOT call ApplyWindowMaterial for notification windows! // ApplyWindowMaterial sets Background to White when MaterialMode is "None", // which causes the white border around the notification card. - // Notification windows must always have transparent background. - Background = Brushes.Transparent; - TransparencyLevelHint = [WindowTransparencyLevel.Transparent]; } - private ThemeColorContext CreateThemeContext(AppearanceThemeSnapshot snapshot) + private ThemeColorContext CreateThemeContext(MaterialColorSnapshot snapshot) { // Create theme context for glass effect resources // Note: IsLightBackground and IsLightNavBackground are derived from IsNightMode // UseNeutralSurfaces is determined by ThemeColorMode - var useNeutralSurfaces = snapshot.ThemeColorMode == "Neutral"; + var useNeutralSurfaces = snapshot.ThemeColorMode == ThemeAppearanceValues.ColorModeDefaultNeutral; var monetColors = snapshot.WallpaperSeedCandidates; return new ThemeColorContext( diff --git a/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml index beee6de..c7064f9 100644 --- a/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml @@ -81,7 +81,7 @@ - @@ -96,16 +96,14 @@ - + - + - - Lincube - + diff --git a/LanMountainDesktop/Views/SettingsPages/AppearanceSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/AppearanceSettingsPage.axaml index 6ae5d35..4a32da1 100644 --- a/LanMountainDesktop/Views/SettingsPages/AppearanceSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/AppearanceSettingsPage.axaml @@ -1,20 +1,18 @@ - - + Description="{Binding ThemeModeDescription}"> @@ -40,33 +38,15 @@ - + - - - - - - - - - - - - - - - - + + ItemsSource="{Binding CornerRadiusStyleOptions}" + SelectedItem="{Binding SelectedCornerRadiusStyle}"> @@ -75,190 +55,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/LanMountainDesktop/Views/SettingsPages/AppearanceSettingsPage.axaml.cs b/LanMountainDesktop/Views/SettingsPages/AppearanceSettingsPage.axaml.cs index 7125102..5b9d4b5 100644 --- a/LanMountainDesktop/Views/SettingsPages/AppearanceSettingsPage.axaml.cs +++ b/LanMountainDesktop/Views/SettingsPages/AppearanceSettingsPage.axaml.cs @@ -1,10 +1,7 @@ -using System; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; using LanMountainDesktop.Services.Settings; using LanMountainDesktop.ViewModels; -using Avalonia.Controls; -using Avalonia.Interactivity; namespace LanMountainDesktop.Views.SettingsPages; @@ -20,8 +17,7 @@ public partial class AppearanceSettingsPage : SettingsPageBase { public AppearanceSettingsPage() : this(new AppearanceSettingsPageViewModel( - HostSettingsFacadeProvider.GetOrCreate(), - HostAppearanceThemeProvider.GetOrCreate())) + HostSettingsFacadeProvider.GetOrCreate())) { } @@ -39,31 +35,4 @@ public partial class AppearanceSettingsPage : SettingsPageBase { RequestRestart(reason); } - - private void OnApplyCustomSeedClick(object? sender, RoutedEventArgs e) - { - _ = sender; - _ = e; - ViewModel.ApplyCustomSeedCommand.Execute(null); - CustomSeedButton?.Flyout?.Hide(); - } - - private void OnCustomSeedFlyoutClosed(object? sender, EventArgs e) - { - _ = sender; - _ = e; - ViewModel.CancelCustomSeedPreview(); - } - - private void OnWallpaperSeedCandidateClick(object? sender, RoutedEventArgs e) - { - _ = e; - - if (sender is Button { DataContext: ThemeSeedCandidateOption option }) - { - ViewModel.SelectWallpaperSeed(option.Value); - } - - WallpaperSeedButton?.Flyout?.Hide(); - } } diff --git a/LanMountainDesktop/Views/SettingsPages/ComponentsSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/ComponentsSettingsPage.axaml index ec7628a..3c4c6e2 100644 --- a/LanMountainDesktop/Views/SettingsPages/ComponentsSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/ComponentsSettingsPage.axaml @@ -1,4 +1,4 @@ - + + + + + + + + @@ -89,7 +115,7 @@ - diff --git a/LanMountainDesktop/Views/SettingsPages/ComponentsSettingsPage.axaml.cs b/LanMountainDesktop/Views/SettingsPages/ComponentsSettingsPage.axaml.cs index f553ed1..b5a344e 100644 --- a/LanMountainDesktop/Views/SettingsPages/ComponentsSettingsPage.axaml.cs +++ b/LanMountainDesktop/Views/SettingsPages/ComponentsSettingsPage.axaml.cs @@ -1,3 +1,6 @@ +using System.Linq; +using Avalonia.Controls; +using Avalonia.Platform; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services.Settings; using LanMountainDesktop.ViewModels; @@ -24,6 +27,26 @@ public partial class ComponentsSettingsPage : SettingsPageBase ViewModel = viewModel; DataContext = ViewModel; InitializeComponent(); + InitScreenAspectRatio(); + } + + private void InitScreenAspectRatio() + { + try + { + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel is null) return; + + var screen = topLevel.Screens.Primary ?? topLevel.Screens.All.FirstOrDefault(); + if (screen is not null && screen.Bounds.Height > 0) + { + ViewModel.ScreenAspectRatio = (double)screen.Bounds.Width / screen.Bounds.Height; + } + } + catch + { + // 无法获取屏幕信息时保持默认 16:9 + } } public ComponentsSettingsPageViewModel ViewModel { get; } diff --git a/LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml new file mode 100644 index 0000000..7b083a4 --- /dev/null +++ b/LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs b/LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs new file mode 100644 index 0000000..a73c4e8 --- /dev/null +++ b/LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs @@ -0,0 +1,127 @@ +using System; +using System.ComponentModel; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.ViewModels; + +namespace LanMountainDesktop.Views.SettingsPages; + +[SettingsPageInfo( + "data", + "数据", + SettingsPageCategory.General, + IconKey = "HardDrive", + SortOrder = 5, + TitleLocalizationKey = "settings.data.title", + DescriptionLocalizationKey = "settings.data.description")] +public partial class DataSettingsPage : SettingsPageBase +{ + private readonly SolidColorBrush _fallbackBrush = new(Colors.Gray); + + public DataSettingsPage() + : this(new DataSettingsPageViewModel()) + { + } + + public DataSettingsPage(DataSettingsPageViewModel viewModel) + { + ViewModel = viewModel; + DataContext = ViewModel; + InitializeComponent(); + AttachStorageBarObservers(); + RebuildStorageBar(); + } + + public DataSettingsPageViewModel ViewModel { get; } + + private void AttachStorageBarObservers() + { + ViewModel.PropertyChanged += OnViewModelPropertyChanged; + foreach (var item in ViewModel.Items) + { + item.PropertyChanged += OnStorageItemPropertyChanged; + } + } + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (string.Equals(e.PropertyName, nameof(DataSettingsPageViewModel.Items), StringComparison.Ordinal)) + { + RebuildStorageBar(); + return; + } + + if (string.Equals(e.PropertyName, nameof(DataSettingsPageViewModel.HasData), StringComparison.Ordinal) || + string.Equals(e.PropertyName, nameof(DataSettingsPageViewModel.DiskUsagePercentage), StringComparison.Ordinal)) + { + RebuildStorageBar(); + } + } + + private void OnStorageItemPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (string.Equals(e.PropertyName, nameof(DataStorageItemViewModel.Percentage), StringComparison.Ordinal) || + string.Equals(e.PropertyName, nameof(DataStorageItemViewModel.ColorHex), StringComparison.Ordinal)) + { + RebuildStorageBar(); + } + } + + private void RebuildStorageBar() + { + if (StorageBarGrid is null) + { + return; + } + + StorageBarGrid.ColumnDefinitions.Clear(); + StorageBarGrid.Children.Clear(); + + var visibleItems = ViewModel.Items + .Where(item => item.Percentage > 0) + .OrderByDescending(item => item.Percentage) + .ToList(); + + var idx = 0; + foreach (var item in visibleItems) + { + var width = Math.Max(0.1, item.Percentage); + StorageBarGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(width, GridUnitType.Star))); + var segment = new Border + { + Background = ParseBrush(item.ColorHex), + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch + }; + Grid.SetColumn(segment, idx++); + StorageBarGrid.Children.Add(segment); + } + + var remaining = 100d - ViewModel.DiskUsagePercentage; + if (remaining > 0) + { + StorageBarGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(remaining, GridUnitType.Star))); + } + } + + private IBrush ParseBrush(string? hex) + { + if (string.IsNullOrWhiteSpace(hex)) + { + return _fallbackBrush; + } + + try + { + return new SolidColorBrush(Color.Parse(hex)); + } + catch + { + return _fallbackBrush; + } + } +} diff --git a/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml index 8c19024..0954d83 100644 --- a/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml @@ -10,16 +10,16 @@ - + @@ -28,8 +28,8 @@ - + @@ -38,26 +38,40 @@ - + - + + + + + + + + + + - + @@ -65,26 +79,26 @@ - + - - - + Text="{Binding OtherDevModeLine}" /> + Text="{Binding OtherHotReloadLine}" /> diff --git a/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml.cs b/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml.cs index 65fc06c..f697688 100644 --- a/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml.cs +++ b/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml.cs @@ -1,3 +1,7 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.VisualTree; +using FluentAvalonia.UI.Controls; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services.Settings; using LanMountainDesktop.ViewModels; @@ -14,6 +18,9 @@ namespace LanMountainDesktop.Views.SettingsPages; DescriptionLocalizationKey = "settings.dev.description")] public partial class DevSettingsPage : SettingsPageBase { + private bool _isReady; + private bool _syncingToggles; + public DevSettingsPage() : this(new DevSettingsPageViewModel(HostSettingsFacadeProvider.GetOrCreate())) { @@ -24,7 +31,92 @@ public partial class DevSettingsPage : SettingsPageBase ViewModel = viewModel; DataContext = ViewModel; InitializeComponent(); + _isReady = true; } public DevSettingsPageViewModel ViewModel { get; } + + private async void OnFusedDesktopToggleChanged(object? sender, RoutedEventArgs e) + { + if (!_isReady || _syncingToggles || sender is not ToggleSwitch toggle) + { + return; + } + + var requested = toggle.IsChecked == true; + if (!requested) + { + ViewModel.ApplyFusedDesktopPreference(enabled: false, disableMainWindowDesktopLayer: false); + SyncTogglesFromViewModel(); + return; + } + + if (ViewModel.EnableMainWindowDesktopLayer && + !await ConfirmDesktopLayerSwitchAsync(ViewModel.DesktopLayerConflictEnableFusedMessage).ConfigureAwait(true)) + { + SyncTogglesFromViewModel(); + return; + } + + ViewModel.ApplyFusedDesktopPreference(enabled: true, disableMainWindowDesktopLayer: true); + SyncTogglesFromViewModel(); + } + + private async void OnMainWindowDesktopLayerToggleChanged(object? sender, RoutedEventArgs e) + { + if (!_isReady || _syncingToggles || sender is not ToggleSwitch toggle) + { + return; + } + + var requested = toggle.IsChecked == true; + if (!requested) + { + ViewModel.ApplyMainWindowDesktopLayerPreference(enabled: false, disableFusedDesktop: false); + SyncTogglesFromViewModel(); + return; + } + + if (ViewModel.EnableFusedDesktop && + !await ConfirmDesktopLayerSwitchAsync(ViewModel.DesktopLayerConflictEnableMainMessage).ConfigureAwait(true)) + { + SyncTogglesFromViewModel(); + return; + } + + ViewModel.ApplyMainWindowDesktopLayerPreference(enabled: true, disableFusedDesktop: true); + SyncTogglesFromViewModel(); + } + + private async Task ConfirmDesktopLayerSwitchAsync(string message) + { + var dialog = new FAContentDialog + { + Title = ViewModel.DesktopLayerConflictTitle, + Content = message, + PrimaryButtonText = ViewModel.DesktopLayerConflictConfirmText, + CloseButtonText = ViewModel.DesktopLayerConflictCancelText, + DefaultButton = FAContentDialogButton.Close + }; + + var owner = this.FindAncestorOfType(); + var result = owner is not null + ? await dialog.ShowAsync(owner) + : await dialog.ShowAsync(); + return result == FAContentDialogResult.Primary; + } + + private void SyncTogglesFromViewModel() + { + _syncingToggles = true; + try + { + FusedDesktopToggle.IsChecked = ViewModel.EnableFusedDesktop; + MainWindowDesktopLayerToggle.IsChecked = ViewModel.EnableMainWindowDesktopLayer; + } + finally + { + _syncingToggles = false; + } + } } diff --git a/LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml index e1021b4..1a2a9e7 100644 --- a/LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml @@ -47,6 +47,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -103,7 +236,7 @@ - @@ -115,8 +248,8 @@ - @@ -126,8 +259,8 @@ - + diff --git a/LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml index 8394886..8ee66d9 100644 --- a/LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml @@ -1,63 +1,21 @@ - - - - - - - - - - - - - - - - - - - - + - @@ -76,14 +34,16 @@ - + - + + + diff --git a/LanMountainDesktop/Views/SettingsPages/MaterialColorSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/MaterialColorSettingsPage.axaml new file mode 100644 index 0000000..b77c8eb --- /dev/null +++ b/LanMountainDesktop/Views/SettingsPages/MaterialColorSettingsPage.axaml @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/SettingsPages/MaterialColorSettingsPage.axaml.cs b/LanMountainDesktop/Views/SettingsPages/MaterialColorSettingsPage.axaml.cs new file mode 100644 index 0000000..e9799ba --- /dev/null +++ b/LanMountainDesktop/Views/SettingsPages/MaterialColorSettingsPage.axaml.cs @@ -0,0 +1,45 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.Services; +using LanMountainDesktop.Services.Settings; +using LanMountainDesktop.ViewModels; + +namespace LanMountainDesktop.Views.SettingsPages; + +[SettingsPageInfo( + "material-color", + "Material & Color", + SettingsPageCategory.Appearance, + IconKey = "Color", + SortOrder = 8, + TitleLocalizationKey = "settings.material_color.title", + DescriptionLocalizationKey = "settings.material_color.description")] +public partial class MaterialColorSettingsPage : SettingsPageBase +{ + public MaterialColorSettingsPage() + : this(new MaterialColorSettingsPageViewModel( + HostSettingsFacadeProvider.GetOrCreate(), + HostMaterialColorProvider.GetOrCreate())) + { + } + + public MaterialColorSettingsPage(MaterialColorSettingsPageViewModel viewModel) + { + ViewModel = viewModel; + DataContext = ViewModel; + InitializeComponent(); + } + + public MaterialColorSettingsPageViewModel ViewModel { get; } + + private void OnWallpaperSeedCandidateClick(object? sender, RoutedEventArgs e) + { + _ = e; + + if (sender is Button { DataContext: ThemeSeedCandidateOption option }) + { + ViewModel.SelectWallpaperSeed(option.Value); + } + } +} diff --git a/LanMountainDesktop/Views/SettingsPages/NotificationSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/NotificationSettingsPage.axaml index e228295..cb21843 100644 --- a/LanMountainDesktop/Views/SettingsPages/NotificationSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/NotificationSettingsPage.axaml @@ -65,6 +65,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/SettingsPages/NotificationSettingsPage.axaml.cs b/LanMountainDesktop/Views/SettingsPages/NotificationSettingsPage.axaml.cs index 7630367..a20c016 100644 --- a/LanMountainDesktop/Views/SettingsPages/NotificationSettingsPage.axaml.cs +++ b/LanMountainDesktop/Views/SettingsPages/NotificationSettingsPage.axaml.cs @@ -6,7 +6,7 @@ namespace LanMountainDesktop.Views.SettingsPages; [SettingsPageInfo( "notifications", - "通知", + "Notifications", SettingsPageCategory.Components, IconKey = "Bell", SortOrder = 5, diff --git a/LanMountainDesktop/Views/SettingsPages/StatusBarSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/StatusBarSettingsPage.axaml index 689e1a5..b6d3481 100644 --- a/LanMountainDesktop/Views/SettingsPages/StatusBarSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/StatusBarSettingsPage.axaml @@ -110,7 +110,7 @@ Height="100" IsEnabled="{Binding ShowTextCapsule}" Text="{Binding TextCapsuleContent}" - PlaceholderText="Enter Markdown text..." /> + Watermark="{Binding TextCapsulePlaceholder}" /> diff --git a/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml index e2c540a..36a9f39 100644 --- a/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml @@ -1,285 +1,295 @@ - - - + x:DataType="vm:UpdateSettingsViewModel"> + + + + + + - + - + + + + + + - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml.cs b/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml.cs index ad2046b..f89b3b5 100644 --- a/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml.cs +++ b/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml.cs @@ -1,5 +1,6 @@ using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services.Settings; +using LanMountainDesktop.Services.Update; using LanMountainDesktop.ViewModels; namespace LanMountainDesktop.Views.SettingsPages; @@ -15,16 +16,18 @@ namespace LanMountainDesktop.Views.SettingsPages; public partial class UpdateSettingsPage : SettingsPageBase { public UpdateSettingsPage() - : this(new UpdateSettingsPageViewModel(HostSettingsFacadeProvider.GetOrCreate())) + : this(new UpdateSettingsViewModel( + HostUpdateOrchestratorProvider.GetOrCreate(), + HostSettingsFacadeProvider.GetOrCreate())) { } - public UpdateSettingsPage(UpdateSettingsPageViewModel viewModel) + public UpdateSettingsPage(UpdateSettingsViewModel viewModel) { ViewModel = viewModel; DataContext = ViewModel; InitializeComponent(); } - public UpdateSettingsPageViewModel ViewModel { get; } + public UpdateSettingsViewModel ViewModel { get; } } diff --git a/LanMountainDesktop/Views/SettingsPages/WallpaperSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/WallpaperSettingsPage.axaml index f76fe93..e43df37 100644 --- a/LanMountainDesktop/Views/SettingsPages/WallpaperSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/WallpaperSettingsPage.axaml @@ -3,7 +3,6 @@ xmlns:vm="using:LanMountainDesktop.ViewModels" xmlns:controls="using:LanMountainDesktop.Controls" xmlns:ui="using:FluentAvalonia.UI.Controls" - xmlns:fi="using:FluentIcons.Avalonia" x:Class="LanMountainDesktop.Views.SettingsPages.WallpaperSettingsPage" x:DataType="vm:WallpaperSettingsPageViewModel"> @@ -168,7 +167,7 @@ Background="{Binding CustomColorBrush}" BorderThickness="0" CornerRadius="6" - ToolTip.Tip="Custom color"> + ToolTip.Tip="{Binding CustomColorTooltip}"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/LanMountainDesktop/Views/SettingsPages/WeatherSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/WeatherSettingsPage.axaml index 5bf7b2a..c60d39f 100644 --- a/LanMountainDesktop/Views/SettingsPages/WeatherSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/WeatherSettingsPage.axaml @@ -10,51 +10,168 @@ - - - - - - - - - - - - - - - - - + + + + + + - - - - - - - - - - - - + Grid.Row="1" + Margin="0" + Background="Transparent" + PaneDisplayMode="Auto" + OpenPaneLength="283" + IsSettingsVisible="False" + IsBackButtonVisible="False" + IsPaneToggleButtonVisible="False" + SelectionChanged="OnNavigationSelectionChanged" + ItemInvoked="OnNavigationItemInvoked"> + + + + @@ -143,7 +108,12 @@ + Grid.Row="1" /> + + @@ -181,5 +151,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + diff --git a/LanMountainDesktop/Views/SettingsWindow.axaml.cs b/LanMountainDesktop/Views/SettingsWindow.axaml.cs index 0608187..916cc36 100644 --- a/LanMountainDesktop/Views/SettingsWindow.axaml.cs +++ b/LanMountainDesktop/Views/SettingsWindow.axaml.cs @@ -5,9 +5,11 @@ using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Input; -using Avalonia.Platform; +using Avalonia.Media; using Avalonia.Threading; +using Avalonia.VisualTree; using FluentAvalonia.UI.Controls; +using FluentAvalonia.UI.Windowing; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; using LanMountainDesktop.Services.Settings; @@ -16,8 +18,10 @@ using Symbol = FluentIcons.Common.Symbol; namespace LanMountainDesktop.Views; -public partial class SettingsWindow : Window, ISettingsPageHostContext +public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext { + public static AutoCompleteFilterPredicate SettingsSearchFilter => SettingsSearchService.Filter; + private const double BaseSettingsContainerWidth = 960d; private const double MinSettingsContentWidth = 320d; private const double MinSettingsContainerWidth = 840d; @@ -27,14 +31,21 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext private const double MinPaneOpenLength = 260d; private const double MaxPaneOpenLength = 288d; private const double BaseNarrowThreshold = 800d; + private const string PaneToggleItemTag = "__pane_toggle__"; private readonly ISettingsPageRegistry _pageRegistry; private readonly IHostApplicationLifecycle _hostApplicationLifecycle; private readonly IAppLogoService _appLogoService = HostAppLogoProvider.GetOrCreate(); + private readonly SettingsSearchService _searchService = new(); private readonly Dictionary _cachedPages = new(StringComparer.OrdinalIgnoreCase); + private readonly Stack _navigationBackStack = new(); private bool _useSystemChrome; private bool _isResponsiveRefreshPending; private bool _isRestartPromptVisible; + private bool _isHandlingSearchSelection; + private FANavigationViewItem? _paneToggleItem; + private Border? _currentSearchHighlight; + private Action? _searchHighlightCleanup; public SettingsWindow() : this( @@ -56,7 +67,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext _hostApplicationLifecycle = hostApplicationLifecycle; DataContext = ViewModel; InitializeComponent(); - Icon = _appLogoService.CreateWindowIcon(); + SetValue(Window.IconProperty, _appLogoService.CreateWindowIcon()); ApplyChromeMode(useSystemChrome); if (RootNavigationView is not null) @@ -75,10 +86,18 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext private void OnLoaded(object? sender, Avalonia.Interactivity.RoutedEventArgs e) { + TitleBar.Height = 48; + TitleBar.ExtendsContentIntoTitleBar = true; + + // SecRandom MainWindow:标题栏按钮悬停/按下/非活动色,与系统 caption 更一致 + TitleBar.ButtonHoverBackgroundColor = Color.FromArgb(23, 0, 0, 0); + TitleBar.ButtonPressedBackgroundColor = Color.FromArgb(52, 0, 0, 0); + TitleBar.ButtonInactiveForegroundColor = Colors.Gray; + SyncPendingRestartState(); SyncTitleText(); UpdateChromeMetrics(); - UpdatePaneToggleIcon(); + UpdatePaneToggleVisibility(); UpdateResponsiveLayout(); RequestResponsiveLayoutRefresh(); } @@ -92,9 +111,13 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext } _cachedPages.Clear(); + _navigationBackStack.Clear(); + ViewModel.CanGoBack = false; CloseDrawer(); RebuildNavigationItems(); - NavigateTo(pageId ?? ViewModel.Pages.FirstOrDefault()?.PageId); + NavigateTo(pageId ?? ViewModel.Pages.FirstOrDefault()?.PageId, addHistory: false, source: "reload"); + RebuildSearchIndex(scanBuiltInPages: true); + UpdatePaneToggleVisibility(); } public void RebuildAndNavigateToDevPage() @@ -160,11 +183,11 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext { _useSystemChrome = useSystemChrome || OperatingSystem.IsMacOS(); + ExtendClientAreaToDecorationsHint = true; + WindowDecorations = WindowDecorations.Full; + if (_useSystemChrome) { - ExtendClientAreaToDecorationsHint = true; - WindowDecorations = WindowDecorations.Full; - if (WindowTitleBarHost is { }) { WindowTitleBarHost.IsVisible = false; @@ -172,9 +195,6 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext return; } - WindowDecorations = WindowDecorations.BorderOnly; - ExtendClientAreaToDecorationsHint = true; - if (WindowTitleBarHost is { }) { WindowTitleBarHost.IsVisible = true; @@ -195,21 +215,42 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext } RootNavigationView.MenuItems.Clear(); + RootNavigationView.FooterMenuItems.Clear(); + + _paneToggleItem = new FANavigationViewItem + { + Content = string.Empty, + Tag = PaneToggleItemTag, + IconSource = CreateSettingsIconSource(Symbol.Navigation), + SelectsOnInvoked = false + }; + ToolTip.SetTip(_paneToggleItem, ViewModel.TogglePaneTooltip); + RootNavigationView.MenuItems.Add(_paneToggleItem); + SettingsPageCategory? previousCategory = null; foreach (var page in ViewModel.Pages) { + var item = new FANavigationViewItem + { + Content = page.Title, + Tag = page.PageId, + IconSource = CreateSettingsIconSource(MapIcon(page.IconKey)) + }; + + if (page.Category == SettingsPageCategory.About || + page.Category == SettingsPageCategory.Dev) + { + RootNavigationView.FooterMenuItems.Add(item); + continue; + } + if (previousCategory is not null && previousCategory != page.Category) { RootNavigationView.MenuItems.Add(new FANavigationViewItemSeparator()); } - RootNavigationView.MenuItems.Add(new FANavigationViewItem - { - Content = page.Title, - Tag = page.PageId, - IconSource = CreateSettingsIconSource(MapIcon(page.IconKey)) - }); + RootNavigationView.MenuItems.Add(item); previousCategory = page.Category; } @@ -217,11 +258,35 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext private void OnNavigationSelectionChanged(object? sender, FANavigationViewSelectionChangedEventArgs e) { - var selectedItem = e.SelectedItemContainer ?? e.SelectedItem as FANavigationViewItem; - NavigateTo(selectedItem?.Tag as string); + _ = sender; + var selectedItem = e.SelectedItemContainer ?? e.SelectedItem as Control; + if (IsPaneToggleItem(selectedItem)) + { + RestoreCurrentNavigationSelection(); + return; + } + + NavigateTo(selectedItem?.Tag as string, addHistory: true, source: "navigation"); } - private void NavigateTo(string? pageId) + private void OnNavigationItemInvoked(object? sender, FANavigationViewItemInvokedEventArgs e) + { + _ = sender; + var invokedItem = e.InvokedItemContainer ?? e.InvokedItem as Control; + if (!IsPaneToggleItem(invokedItem)) + { + return; + } + + ToggleNavigationPane(); + RestoreCurrentNavigationSelection(); + } + + private void NavigateTo( + string? pageId, + bool addHistory, + string source, + SettingsSearchResult? searchResult = null) { var previousPageId = ViewModel.CurrentPageId; var descriptor = ResolveDescriptor(pageId); @@ -230,6 +295,21 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext return; } + if (string.Equals(previousPageId, descriptor.PageId, StringComparison.OrdinalIgnoreCase)) + { + if (searchResult is not null) + { + HighlightSearchResult(searchResult); + } + + return; + } + + if (addHistory && !string.IsNullOrWhiteSpace(previousPageId)) + { + _navigationBackStack.Push(previousPageId); + } + var page = GetOrCreatePage(descriptor); if (page is SettingsPageBase settingsPage) { @@ -247,13 +327,21 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext ViewModel.CurrentPageDescription = descriptor.Description; ViewModel.CurrentPageId = descriptor.PageId; ViewModel.IsPageTitleVisible = !descriptor.HidePageTitle; + ViewModel.CanGoBack = _navigationBackStack.Count > 0; + CloseDrawer(); TrySelectNavigationItem(descriptor.PageId); SyncTitleText(); + UpdatePaneToggleVisibility(); UpdateResponsiveLayout(); RequestResponsiveLayoutRefresh(); + if (searchResult is not null) + { + HighlightSearchResult(searchResult); + } + if (!string.Equals(previousPageId, descriptor.PageId, StringComparison.OrdinalIgnoreCase)) { - TelemetryServices.Usage?.TrackSettingsNavigation(previousPageId, descriptor.PageId, "navigation"); + TelemetryServices.Usage?.TrackSettingsNavigation(previousPageId, descriptor.PageId, source); } } @@ -283,9 +371,34 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext } _cachedPages[descriptor.PageId] = page; + _searchService.IndexPage(descriptor, page); return page; } + private void RebuildSearchIndex(bool scanBuiltInPages) + { + _searchService.RebuildPageEntries(ViewModel.Pages); + + if (scanBuiltInPages) + { + foreach (var descriptor in ViewModel.Pages.Where(static page => page.IsBuiltIn)) + { + _ = GetOrCreatePage(descriptor); + } + } + + SyncSearchResults(); + } + + private void SyncSearchResults() + { + ViewModel.SearchResults.Clear(); + foreach (var result in _searchService.Entries) + { + ViewModel.SearchResults.Add(result); + } + } + private void TrySelectNavigationItem(string pageId) { if (RootNavigationView is null) @@ -293,7 +406,10 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext return; } - foreach (var item in RootNavigationView.MenuItems.OfType()) + var allItems = RootNavigationView.MenuItems.OfType() + .Concat(RootNavigationView.FooterMenuItems.OfType()); + + foreach (var item in allItems) { if (string.Equals(item.Tag as string, pageId, StringComparison.OrdinalIgnoreCase)) { @@ -317,6 +433,77 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext CloseDrawer(); } + private void OnBackButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + _ = sender; + _ = e; + + while (_navigationBackStack.Count > 0) + { + var pageId = _navigationBackStack.Pop(); + if (ResolveDescriptor(pageId) is not null) + { + NavigateTo(pageId, addHistory: false, source: "back"); + return; + } + } + + ViewModel.CanGoBack = false; + } + + private void OnRestartMenuItemClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + _ = sender; + _ = e; + ShowRestartPrompt(); + } + + private void OnSearchBoxKeyUp(object? sender, KeyEventArgs e) + { + if (e.Key != Key.Enter) + { + return; + } + + var selected = ViewModel.SelectedSearchResult; + if (selected is null && SettingsSearchBox is not null) + { + selected = _searchService.Search(SettingsSearchBox.Text, maxResults: 1).FirstOrDefault(); + } + + NavigateToSearchResult(selected); + } + + private void OnSearchBoxSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + _ = sender; + if (_isHandlingSearchSelection || e.AddedItems.Count == 0) + { + return; + } + + NavigateToSearchResult(e.AddedItems[0] as SettingsSearchResult); + } + + private void NavigateToSearchResult(SettingsSearchResult? result) + { + if (result is null) + { + return; + } + + _isHandlingSearchSelection = true; + try + { + NavigateTo(result.PageId, addHistory: true, source: "search", searchResult: result); + ViewModel.SelectedSearchResult = null; + } + finally + { + _isHandlingSearchSelection = false; + } + } + private void OnPendingRestartStateChanged() { SyncPendingRestartState(); @@ -481,8 +668,125 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext // Hide the drawer pane on narrow windows. } + private void HighlightSearchResult(SettingsSearchResult result) + { + var target = result.TargetControl; + if (target is null) + { + return; + } + + Dispatcher.UIThread.Post( + () => + { + ExpandSearchTarget(target); + target.BringIntoView(); + target.Focus(); + ShowSearchHighlight(target); + }, + DispatcherPriority.Render); + } + + private static void ExpandSearchTarget(Control target) + { + if (target is FASettingsExpander expander) + { + expander.IsExpanded = true; + } + + foreach (var ancestor in target.GetVisualAncestors().OfType()) + { + ancestor.IsExpanded = true; + } + } + + private void ShowSearchHighlight(Control target) + { + RemoveSearchHighlight(); + + if (SearchHighlightOverlay is null || target.Bounds.Width <= 0 || target.Bounds.Height <= 0) + { + return; + } + + var transform = target.TransformToVisual(SearchHighlightOverlay); + if (transform is null) + { + return; + } + + var position = transform.Value.Transform(new Point(0, 0)); + var accent = HostAppearanceThemeProvider.GetOrCreate().GetCurrent().AccentColor; + var highlight = new Border + { + Width = target.Bounds.Width, + Height = target.Bounds.Height, + Background = new SolidColorBrush(Color.FromArgb(34, accent.R, accent.G, accent.B)), + BorderBrush = new SolidColorBrush(Color.FromArgb(210, accent.R, accent.G, accent.B)), + BorderThickness = new Thickness(2), + CornerRadius = new CornerRadius(8), + IsHitTestVisible = false + }; + + Canvas.SetLeft(highlight, position.X); + Canvas.SetTop(highlight, position.Y); + SearchHighlightOverlay.Children.Add(highlight); + _currentSearchHighlight = highlight; + + void OnLayoutUpdated(object? sender, EventArgs e) + { + _ = sender; + _ = e; + if (_currentSearchHighlight != highlight || SearchHighlightOverlay is null) + { + return; + } + + var nextTransform = target.TransformToVisual(SearchHighlightOverlay); + if (nextTransform is null) + { + return; + } + + var nextPosition = nextTransform.Value.Transform(new Point(0, 0)); + Canvas.SetLeft(highlight, nextPosition.X); + Canvas.SetTop(highlight, nextPosition.Y); + highlight.Width = target.Bounds.Width; + highlight.Height = target.Bounds.Height; + } + + target.LayoutUpdated += OnLayoutUpdated; + _searchHighlightCleanup = () => + { + target.LayoutUpdated -= OnLayoutUpdated; + SearchHighlightOverlay?.Children.Remove(highlight); + }; + + var timer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(2.4) + }; + timer.Tick += (_, _) => + { + timer.Stop(); + if (_currentSearchHighlight == highlight) + { + RemoveSearchHighlight(); + } + }; + timer.Start(); + } + + private void RemoveSearchHighlight() + { + _searchHighlightCleanup?.Invoke(); + _searchHighlightCleanup = null; + _currentSearchHighlight = null; + } + private void OnClosed(object? sender, EventArgs e) { + RemoveSearchHighlight(); _cachedPages.Clear(); PendingRestartStateService.StateChanged -= OnPendingRestartStateChanged; if (RootNavigationView is not null) @@ -494,50 +798,98 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext TelemetryServices.Usage?.TrackSettingsWindowClosed("SettingsWindow.OnClosed", ViewModel.CurrentPageId); } - private void OnWindowTitleBarPointerPressed(object? sender, PointerPressedEventArgs e) + private void OnTitleBarDragZonePointerPressed(object? sender, PointerPressedEventArgs e) { _ = sender; - if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || IsInteractiveTitleBarSource(e.Source as Control)) { - BeginMoveDrag(e); + return; } + + BeginMoveDrag(e); } - private void OnTogglePaneButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + private bool IsInteractiveTitleBarSource(Control? source) + { + if (source is null) + { + return false; + } + + IEnumerable controls = source.GetVisualAncestors().OfType().Prepend(source); + foreach (var control in controls) + { + if (ReferenceEquals(control, WindowTitleBarHost)) + { + return false; + } + + if (control is Button or AutoCompleteBox or TextBox or MenuItem) + { + return true; + } + } + + return false; + } + + private void ToggleNavigationPane() { - _ = sender; - _ = e; if (RootNavigationView is null) { return; } RootNavigationView.IsPaneOpen = !RootNavigationView.IsPaneOpen; - UpdatePaneToggleIcon(); UpdateResponsiveLayout(); RequestResponsiveLayoutRefresh(); } - private void OnCloseWindowClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) - { - _ = sender; - _ = e; - Close(); - } - private void OnRootNavigationViewPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) { _ = sender; if (e.Property == FANavigationView.IsPaneOpenProperty || e.Property == FANavigationView.OpenPaneLengthProperty || - e.Property == FANavigationView.PaneDisplayModeProperty) + e.Property == FANavigationView.PaneDisplayModeProperty || + e.Property == FANavigationView.IsPaneToggleButtonVisibleProperty) { - UpdatePaneToggleIcon(); + if (e.Property == FANavigationView.IsPaneToggleButtonVisibleProperty) + { + UpdatePaneToggleVisibility(); + } + RequestResponsiveLayoutRefresh(); } } + /// + /// The NavigationView template toggle is disabled; the first menu item is the only pane toggle. + /// + private void UpdatePaneToggleVisibility() + { + if (_paneToggleItem is null) + { + return; + } + + _paneToggleItem.IsVisible = true; + ToolTip.SetTip(_paneToggleItem, ViewModel.TogglePaneTooltip); + } + + private static bool IsPaneToggleItem(Control? item) + { + return string.Equals(item?.Tag as string, PaneToggleItemTag, StringComparison.Ordinal); + } + + private void RestoreCurrentNavigationSelection() + { + if (!string.IsNullOrWhiteSpace(ViewModel.CurrentPageId)) + { + TrySelectNavigationItem(ViewModel.CurrentPageId); + } + } + private void RequestResponsiveLayoutRefresh() { if (_isResponsiveRefreshPending) @@ -567,14 +919,6 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext : compactPaneWidth; } - private void UpdatePaneToggleIcon() - { - if (TogglePaneButtonIcon is null || RootNavigationView is null) - { - return; - } - } - private void UpdateChromeMetrics() { if (_useSystemChrome) @@ -587,15 +931,11 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext } if (WindowTitleBarHost is null || - TogglePaneButton is null || - TogglePaneButtonIcon is null || WindowBrandIcon is null || WindowTitleTextBlock is null || RestartNowButton is null || RestartButtonIcon is null || RestartButtonTextBlock is null || - CloseWindowButton is null || - CloseWindowButtonIcon is null || DrawerTitleTextBlock is null || RootNavigationView is null) { @@ -606,9 +946,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext var height = Bounds.Height > 1 ? Bounds.Height : Math.Max(Height, MinHeight); var layoutScale = Math.Clamp(Math.Min(width / 1120d, height / 760d), 0.90, 1.18); - var titleBarHeight = Math.Clamp(48d * layoutScale, 44d, 58d); - var titleBarButtonWidth = Math.Clamp(40d * layoutScale, 36d, 48d); - var titleBarButtonHeight = Math.Clamp(32d * layoutScale, 30d, 38d); + const double titleBarHeight = 48d; var titleFontSize = Math.Clamp(12d * layoutScale, 11d, 14d); var titleBarIconSize = Math.Clamp(16d * layoutScale, 15d, 20d); var drawerTitleFontSize = Math.Clamp(16d * layoutScale, 14d, 20d); @@ -618,11 +956,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext ExtendClientAreaTitleBarHeightHint = titleBarHeight; WindowTitleBarHost.Height = titleBarHeight; - WindowTitleBarHost.Padding = new Thickness(chromePadding, 0, chromePadding, 0); - TogglePaneButton.Width = titleBarButtonWidth; - TogglePaneButton.Height = titleBarButtonHeight; - TogglePaneButtonIcon.FontSize = titleBarIconSize; WindowBrandIcon.FontSize = titleBarIconSize + 2; WindowTitleTextBlock.FontSize = titleFontSize; @@ -636,10 +970,6 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext RestartButtonIcon.FontSize = titleBarIconSize; RestartButtonTextBlock.FontSize = titleFontSize; - CloseWindowButton.Width = titleBarButtonWidth; - CloseWindowButton.Height = titleBarButtonHeight; - CloseWindowButtonIcon.FontSize = titleBarIconSize; - DrawerTitleTextBlock.FontSize = drawerTitleFontSize; } @@ -737,6 +1067,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext "DeveloperBoard" => Symbol.DeveloperBoard, "FolderLink" => Symbol.FolderLink, "WindowConsole" => Symbol.WindowConsole, + "HardDrive" => Symbol.HardDrive, _ => Symbol.Settings }; } diff --git a/LanMountainDesktop/Views/TransparentOverlayWindow.axaml b/LanMountainDesktop/Views/TransparentOverlayWindow.axaml index c6cd809..ba4caff 100644 --- a/LanMountainDesktop/Views/TransparentOverlayWindow.axaml +++ b/LanMountainDesktop/Views/TransparentOverlayWindow.axaml @@ -1,22 +1,102 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs b/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs index 1ca61b6..3cb56fd 100644 --- a/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs +++ b/LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs @@ -3,30 +3,61 @@ using System.Collections.Generic; using System.Diagnostics; using Avalonia; using Avalonia.Controls; -using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Layout; using Avalonia.Media; +using Avalonia.Threading; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.DesktopEditing; using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; using LanMountainDesktop.Services.Settings; -using LanMountainDesktop.ComponentSystem; using LanMountainDesktop.Views.Components; namespace LanMountainDesktop.Views; -/// -/// 透明覆盖层窗口 - 作为"负一屏"显示在 Windows 桌面上 -/// 支持在系统桌面上自由摆放组件 -/// public partial class TransparentOverlayWindow : Window { + private const double DefaultCellSize = 100; + private const string ResizeHandleTag = "fused-desktop-resize-handle"; + private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate(); private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate(); private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate(); - - // 滑动状态 + private readonly ISettingsFacadeService _settingsFacade; + private readonly FusedDesktopEditGridAdapter _gridAdapter; + + private readonly IWeatherInfoService _weatherDataService; + private readonly TimeZoneService _timeZoneService; + private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService(); + private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService(); + + private readonly Dictionary _componentHosts = []; + private readonly List _interactiveRegions = []; + private FusedDesktopLayoutSnapshot _layout = new(); + private ComponentRegistry? _componentRegistry; + private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry; + private FusedDesktopEditGridContext _gridContext; + private double _currentDesktopCellSize = DefaultCellSize; + + private DesktopEditSession _editSession; + private Border? _interactionHost; + private string? _interactionPlacementId; + private Rect _interactionOriginalRect; + private int _interactionStartRow; + private int _interactionStartColumn; + private int _interactionStartWidthCells; + private int _interactionStartHeightCells; + private int _interactionMinWidthCells; + private int _interactionMinHeightCells; + private int _interactionMaxWidthCells; + private int _interactionMaxHeightCells; + private DesktopComponentResizeMode _interactionResizeMode = DesktopComponentResizeMode.Proportional; + + private Border? _selectedHost; + private bool _isSwipeActive; private bool _isSwipeDirectionLocked; private Point _swipeStartPoint; @@ -35,125 +66,256 @@ public partial class TransparentOverlayWindow : Window private double _swipeVelocityX; private long _swipeLastTimestamp; private int? _swipePointerId; - - // 三指/右键拖动状态 private bool _isThreeFingerOrRightDragSwipeActive; private readonly HashSet _activePointerIds = []; - - // 组件管理 - private readonly Dictionary _componentHosts = []; - private readonly List _interactiveRegions = []; - private FusedDesktopLayoutSnapshot _layout = new(); - private ComponentRegistry? _componentRegistry; - private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry; - - // 基础服务 - private readonly IWeatherInfoService _weatherDataService; - private readonly TimeZoneService _timeZoneService; - private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService(); - private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService(); - - // 渲染参数 - private const double DefaultCellSize = 100; - private double _currentDesktopCellSize; - - // 拖拽与缩放状态 - private bool _isDragging; - private bool _isResizing; - private string? _interactionPlacementId; - private Point _interactionStartPoint; - private double _interactionOriginalX; - private double _interactionOriginalY; - private double _interactionOriginalWidth; - private double _interactionOriginalHeight; - private Border? _interactionHost; - - // 选中状态 - private Border? _selectedHost; - + public event EventHandler? RestoreMainWindowRequested; - + public event EventHandler? ExitEditRequested; + public event EventHandler? RestoreComponentLibraryRequested; + public TransparentOverlayWindow() { InitializeComponent(); + var facade = HostSettingsFacadeProvider.GetOrCreate(); + _settingsFacade = facade; + _gridAdapter = new FusedDesktopEditGridAdapter(_settingsFacade); _weatherDataService = facade.Weather.GetWeatherInfoService(); _timeZoneService = facade.Region.GetTimeZoneService(); - _settingsFacade = facade; + + SizeChanged += OnOverlaySizeChanged; if (OperatingSystem.IsWindows()) { _bottomMostService.SetupBottomMost(this); } } - - private readonly ISettingsFacadeService _settingsFacade; public void SaveLayoutAndHide() { SaveLayout(); _regionPassthroughService.ClearInteractiveRegions(this); Hide(); - - // Remove all components so that next time we open it builds fresh from snapshot - if (Content is Canvas canvas) - { - canvas.Children.Clear(); - } + ComponentCanvas.Children.Clear(); _componentHosts.Clear(); + _selectedHost = null; + _editSession = default; } - + + public void AddComponentToCenter(string componentId) + { + AddComponent(componentId, double.NaN, double.NaN); + } + + public void AddComponent(string componentId, double x, double y, double? width = null, double? height = null) + { + EnsureRegistries(); + + if (_componentRuntimeRegistry is null || + !_componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor)) + { + AppLogger.Warn("TransparentOverlay", $"Cannot add unknown component: {componentId}"); + return; + } + + EnsureGridContext(); + var (widthCells, heightCells) = ResolveRequestedSpan(descriptor.Definition, width, height); + var (column, row) = ResolveRequestedCell(x, y, widthCells, heightCells); + var placement = new FusedDesktopComponentPlacementSnapshot + { + PlacementId = Guid.NewGuid().ToString("N"), + ComponentId = componentId, + GridColumn = column, + GridRow = row, + GridWidthCells = widthCells, + GridHeightCells = heightCells, + ZIndex = _layout.ComponentPlacements.Count + }; + ApplyGridPlacementToPixelPlacement(placement); + + _layout.ComponentPlacements.Add(placement); + try + { + RenderComponentInternal(placement); + UpdateInteractiveRegions(); + SaveLayout(); + AppLogger.Info( + "TransparentOverlay", + $"Added component: {componentId} at cell ({column}, {row}) span ({widthCells}x{heightCells})"); + } + catch (Exception ex) + { + AppLogger.Warn("TransparentOverlay", $"Failed to add component {componentId}", ex); + _layout.ComponentPlacements.Remove(placement); + } + } + + public void RemoveComponent(string placementId) + { + if (_componentHosts.Remove(placementId, out var host)) + { + ComponentCanvas.Children.Remove(host); + } + + _layout.ComponentPlacements.RemoveAll(p => string.Equals(p.PlacementId, placementId, StringComparison.OrdinalIgnoreCase)); + UpdateInteractiveRegions(); + SaveLayout(); + } + + public void RenderComponent(string placementId, Control component, double x, double y, double width, double height) + { + if (_componentHosts.Remove(placementId, out var existingHost)) + { + ComponentCanvas.Children.Remove(existingHost); + } + + component.Width = width; + component.Height = height; + + var contentGrid = new Grid(); + contentGrid.Children.Add(component); + + var resizeHandle = new Border + { + Width = 22, + Height = 22, + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Bottom, + Margin = new Thickness(0, 0, -11, -11), + Cursor = new Cursor(StandardCursorType.BottomRightCorner), + Tag = ResizeHandleTag, + IsVisible = false, + IsHitTestVisible = false, + Classes = { "fused-desktop-resize-handle" } + }; + contentGrid.Children.Add(resizeHandle); + + var host = new Border + { + Tag = placementId, + Width = width, + Height = height, + ClipToBounds = false, + Child = contentGrid, + Classes = { "fused-desktop-component-host" } + }; + + Canvas.SetLeft(host, x); + Canvas.SetTop(host, y); + + host.PointerPressed += OnComponentPointerPressed; + host.PointerMoved += OnInteractionPointerMoved; + host.PointerReleased += OnInteractionPointerReleased; + host.PointerCaptureLost += OnInteractionPointerCaptureLost; + host.ContextRequested += OnComponentContextRequested; + + ComponentCanvas.Children.Add(host); + _componentHosts[placementId] = host; + } + protected override void OnOpened(EventArgs e) { base.OnOpened(e); - - if (Screens.Primary is { } primaryScreen) - { - // 避开系统任务栏 - var workArea = primaryScreen.WorkingArea; - var scaling = primaryScreen.Scaling; - Position = new PixelPoint(workArea.X, workArea.Y); - Width = workArea.Width / scaling; - Height = workArea.Height / scaling; - - // 基于设置计算单元格尺寸 - var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); - var shortCells = Math.Clamp(appSnapshot.GridShortSideCells > 0 ? appSnapshot.GridShortSideCells : 12, 6, 96); - _currentDesktopCellSize = Height / shortCells; - } - else - { - _currentDesktopCellSize = DefaultCellSize; - } - if (Content is Canvas canvas) - { - // 保证透明区域也能被抓取事件 - canvas.Background = new SolidColorBrush(Color.FromArgb(1, 0, 0, 0)); - } - - // 确保注册表已初始化 + ApplyWorkAreaBounds(); + EnsureGridContext(); EnsureRegistries(); - - // 加载布局并渲染 + _layout = _layoutService.Load(); RenderAllComponents(); - - AppLogger.Info("TransparentOverlay", $"Opened with {_layout.ComponentPlacements.Count} components."); - if (OperatingSystem.IsWindows()) - { - _bottomMostService.SendToBottom(this); - } + AppLogger.Info( + "TransparentOverlay", + $"Opened with {_layout.ComponentPlacements.Count} components. WindowRole=DesktopSurface."); + + RefreshDesktopLayer(); + + Dispatcher.UIThread.Post(UpdateInteractiveRegions, DispatcherPriority.Background); + DispatcherTimer.RunOnce(LogTransparencyDiagnostics, TimeSpan.FromMilliseconds(250)); } - - /// - /// 确保组件运行时注册表已初始化 - /// + + public void RefreshDesktopLayer() + { + if (!OperatingSystem.IsWindows() || !IsVisible) + { + return; + } + + _bottomMostService.SendToBottom(this); + AppLogger.Info("TransparentOverlay", "Refreshed desktop layer. WindowRole=DesktopSurface."); + } + + protected override void OnClosed(EventArgs e) + { + SaveLayout(); + base.OnClosed(e); + } + + private void OnOverlaySizeChanged(object? sender, SizeChangedEventArgs e) + { + if (!IsVisible) + { + return; + } + + EnsureGridContext(); + RenderAllComponents(saveIfMigrated: false); + Dispatcher.UIThread.Post(UpdateInteractiveRegions, DispatcherPriority.Background); + } + + private void ApplyWorkAreaBounds() + { + if (Screens.Primary is not { } primaryScreen) + { + return; + } + + var workArea = primaryScreen.WorkingArea; + var scaling = primaryScreen.Scaling; + Position = new PixelPoint(workArea.X, workArea.Y); + Width = workArea.Width / scaling; + Height = workArea.Height / scaling; + } + + private void LogTransparencyDiagnostics() + { + var actualTransparency = ActualTransparencyLevel; + if (actualTransparency == WindowTransparencyLevel.Transparent) + { + AppLogger.Info( + "TransparentOverlay", + $"ActualTransparencyLevel={actualTransparency}; overlay should be visually transparent."); + return; + } + + AppLogger.Warn( + "TransparentOverlay", + $"ActualTransparencyLevel={actualTransparency}; expected Transparent. The platform, window styles, or desktop host attachment may be preventing true transparency."); + } + + private void EnsureGridContext() + { + var viewport = new Size(Math.Max(1, Width), Math.Max(1, Height)); + if (_gridAdapter.TryCreate(viewport, out var context)) + { + _gridContext = context; + _currentDesktopCellSize = context.Geometry.CellSize; + return; + } + + _gridContext = new FusedDesktopEditGridContext( + new DesktopGridGeometry(default, DefaultCellSize, 0, 1, 1), + new DesktopGridMetrics(1, 1, DefaultCellSize, 0, 0, DefaultCellSize, DefaultCellSize)); + _currentDesktopCellSize = DefaultCellSize; + } + private void EnsureRegistries() { - if (_componentRuntimeRegistry is not null) return; - + if (_componentRuntimeRegistry is not null) + { + return; + } + var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService; _componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService); _componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry( @@ -161,22 +323,19 @@ public partial class TransparentOverlayWindow : Window pluginRuntimeService, _settingsFacade); } - - /// - /// 渲染所有布局中的组件 - /// - private void RenderAllComponents() + + private void RenderAllComponents(bool saveIfMigrated = true) { - if (Content is not Canvas canvas) return; - - canvas.Children.Clear(); + ComponentCanvas.Children.Clear(); _componentHosts.Clear(); _selectedHost = null; - + + var migrated = false; foreach (var placement in _layout.ComponentPlacements) { try { + migrated |= EnsurePlacementGridFields(placement); RenderComponentInternal(placement); } catch (Exception ex) @@ -184,19 +343,142 @@ public partial class TransparentOverlayWindow : Window AppLogger.Warn("TransparentOverlay", $"Failed to render component {placement.ComponentId}", ex); } } - + + if (migrated && saveIfMigrated) + { + SaveLayout(); + } + UpdateInteractiveRegions(); } - - protected override void OnClosed(EventArgs e) + + private void RenderComponentInternal(FusedDesktopComponentPlacementSnapshot placement) { - SaveLayout(); - base.OnClosed(e); + if (_componentRuntimeRegistry is null || + !_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var descriptor)) + { + AppLogger.Warn("TransparentOverlay", $"Unknown component: {placement.ComponentId}"); + return; + } + + EnsurePlacementGridFields(placement); + ApplyGridPlacementToPixelPlacement(placement); + + var control = descriptor.CreateControl( + _currentDesktopCellSize, + _timeZoneService, + _weatherDataService, + _recommendationInfoService, + _calculatorDataService, + _settingsFacade, + placement.PlacementId); + + RenderComponent(placement.PlacementId, control, placement.X, placement.Y, placement.Width, placement.Height); } - - /// - /// 更新可交互区域 - /// + + private bool EnsurePlacementGridFields(FusedDesktopComponentPlacementSnapshot placement) + { + if (_componentRuntimeRegistry is null || + !_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var descriptor)) + { + return false; + } + + var grid = _gridContext.Geometry; + var oldRow = placement.GridRow; + var oldColumn = placement.GridColumn; + var oldWidthCells = placement.GridWidthCells; + var oldHeightCells = placement.GridHeightCells; + + var widthCells = placement.GridWidthCells ?? PixelSizeToCellSpan(placement.Width); + var heightCells = placement.GridHeightCells ?? PixelSizeToCellSpan(placement.Height); + (widthCells, heightCells) = ComponentPlacementRules.EnsureMinimumSize( + descriptor.Definition, + widthCells, + heightCells); + widthCells = Math.Clamp(widthCells, 1, Math.Max(1, grid.ColumnCount)); + heightCells = Math.Clamp(heightCells, 1, Math.Max(1, grid.RowCount)); + + var column = placement.GridColumn ?? PixelPositionToCell(placement.X, grid.Origin.X); + var row = placement.GridRow ?? PixelPositionToCell(placement.Y, grid.Origin.Y); + column = Math.Clamp(column, 0, Math.Max(0, grid.ColumnCount - widthCells)); + row = Math.Clamp(row, 0, Math.Max(0, grid.RowCount - heightCells)); + + placement.GridColumn = column; + placement.GridRow = row; + placement.GridWidthCells = widthCells; + placement.GridHeightCells = heightCells; + ApplyGridPlacementToPixelPlacement(placement); + + return oldRow != placement.GridRow || + oldColumn != placement.GridColumn || + oldWidthCells != placement.GridWidthCells || + oldHeightCells != placement.GridHeightCells; + } + + private void ApplyGridPlacementToPixelPlacement(FusedDesktopComponentPlacementSnapshot placement) + { + var grid = _gridContext.Geometry; + var widthCells = Math.Clamp(placement.GridWidthCells ?? 1, 1, Math.Max(1, grid.ColumnCount)); + var heightCells = Math.Clamp(placement.GridHeightCells ?? 1, 1, Math.Max(1, grid.RowCount)); + var column = Math.Clamp(placement.GridColumn ?? 0, 0, Math.Max(0, grid.ColumnCount - widthCells)); + var row = Math.Clamp(placement.GridRow ?? 0, 0, Math.Max(0, grid.RowCount - heightCells)); + var rect = DesktopPlacementMath.GetCellRect(grid, column, row, widthCells, heightCells); + + placement.GridColumn = column; + placement.GridRow = row; + placement.GridWidthCells = widthCells; + placement.GridHeightCells = heightCells; + placement.X = rect.X; + placement.Y = rect.Y; + placement.Width = rect.Width; + placement.Height = rect.Height; + } + + private (int WidthCells, int HeightCells) ResolveRequestedSpan( + DesktopComponentDefinition definition, + double? requestedWidth, + double? requestedHeight) + { + var widthCells = requestedWidth.HasValue ? PixelSizeToCellSpan(requestedWidth.Value) : definition.MinWidthCells; + var heightCells = requestedHeight.HasValue ? PixelSizeToCellSpan(requestedHeight.Value) : definition.MinHeightCells; + (widthCells, heightCells) = ComponentPlacementRules.EnsureMinimumSize(definition, widthCells, heightCells); + widthCells = Math.Clamp(widthCells, 1, Math.Max(1, _gridContext.Geometry.ColumnCount)); + heightCells = Math.Clamp(heightCells, 1, Math.Max(1, _gridContext.Geometry.RowCount)); + return (widthCells, heightCells); + } + + private (int Column, int Row) ResolveRequestedCell(double x, double y, int widthCells, int heightCells) + { + var grid = _gridContext.Geometry; + if (double.IsNaN(x) || double.IsNaN(y)) + { + return ( + Math.Max(0, (grid.ColumnCount - widthCells) / 2), + Math.Max(0, (grid.RowCount - heightCells) / 2)); + } + + var column = PixelPositionToCell(x, grid.Origin.X); + var row = PixelPositionToCell(y, grid.Origin.Y); + return ( + Math.Clamp(column, 0, Math.Max(0, grid.ColumnCount - widthCells)), + Math.Clamp(row, 0, Math.Max(0, grid.RowCount - heightCells))); + } + + private int PixelSizeToCellSpan(double pixels) + { + var grid = _gridContext.Geometry; + var pitch = Math.Max(1, grid.Pitch); + var span = (int)Math.Round((Math.Max(1, pixels) + grid.CellGap) / pitch); + return Math.Max(1, span); + } + + private int PixelPositionToCell(double position, double origin) + { + var pitch = Math.Max(1, _gridContext.Geometry.Pitch); + return (int)Math.Round((position - origin) / pitch); + } + private void UpdateInteractiveRegions() { _interactiveRegions.Clear(); @@ -207,386 +489,434 @@ public partial class TransparentOverlayWindow : Window var top = Canvas.GetTop(host); var width = host.Width > 0 ? host.Width : host.Bounds.Width; var height = host.Height > 0 ? host.Height : host.Bounds.Height; - - if (width <= 0 || height <= 0) + if (width > 0 && height > 0) { - continue; + _interactiveRegions.Add(new Rect(left - 14, top - 14, width + 28, height + 28)); } + } - // 稍微向外扩一圈,确保拖拽和右下角缩放手柄也能命中。 - _interactiveRegions.Add(new Rect(left - 12, top - 12, width + 24, height + 24)); + if (EditToolbar.IsVisible && + EditToolbar.Bounds.Width > 0 && + EditToolbar.Bounds.Height > 0 && + EditToolbar.TranslatePoint(default, this) is { } toolbarOrigin) + { + _interactiveRegions.Add(new Rect(toolbarOrigin, EditToolbar.Bounds.Size)); } _regionPassthroughService.SetInteractiveRegions(this, _interactiveRegions); } - - /// - /// 保存布局 - /// + private void SaveLayout() { _layoutService.Save(_layout); } - - /// - /// 添加组件(供外部调用) - /// - public void AddComponent(string componentId, double x, double y, double? width = null, double? height = null) + + private void OnCanvasPointerPressed(object? sender, PointerPressedEventArgs e) { - EnsureRegistries(); - - if (_componentRegistry == null || !_componentRegistry.TryGetDefinition(componentId, out var definition)) + if (e.Source == ComponentCanvas) + { + DeselectComponent(); + } + } + + private void OnExitEditClick(object? sender, RoutedEventArgs e) + { + ExitEditRequested?.Invoke(this, EventArgs.Empty); + e.Handled = true; + } + + private void OnRestoreComponentLibraryClick(object? sender, RoutedEventArgs e) + { + RestoreComponentLibraryRequested?.Invoke(this, EventArgs.Empty); + e.Handled = true; + } + + private void SelectComponent(Border host) + { + if (_selectedHost == host) { - AppLogger.Warn("TransparentOverlay", $"Cannot add unknown component: {componentId}"); return; } - var finalWidth = width ?? (definition.MinWidthCells * _currentDesktopCellSize); - var finalHeight = height ?? (definition.MinHeightCells * _currentDesktopCellSize); - - // 对齐网格 - x = Math.Round(x / _currentDesktopCellSize) * _currentDesktopCellSize; - y = Math.Round(y / _currentDesktopCellSize) * _currentDesktopCellSize; - finalWidth = Math.Round(finalWidth / _currentDesktopCellSize) * _currentDesktopCellSize; - finalHeight = Math.Round(finalHeight / _currentDesktopCellSize) * _currentDesktopCellSize; - - var placementId = Guid.NewGuid().ToString("N"); - var placement = new FusedDesktopComponentPlacementSnapshot - { - PlacementId = placementId, - ComponentId = componentId, - X = x, - Y = y, - Width = finalWidth, - Height = finalHeight, - ZIndex = _layout.ComponentPlacements.Count - }; - - _layout.ComponentPlacements.Add(placement); - - // 立即渲染 - try - { - RenderComponentInternal(placement); - UpdateInteractiveRegions(); - SaveLayout(); - AppLogger.Info("TransparentOverlay", $"Added component: {componentId} at ({x}, {y}) size ({finalWidth}x{finalHeight})"); - } - catch (Exception ex) - { - AppLogger.Warn("TransparentOverlay", $"Failed to add component {componentId}", ex); - _layout.ComponentPlacements.Remove(placement); - } + DeselectComponent(); + _selectedHost = host; + host.Classes.Add("selected"); + SetResizeHandleVisible(host, true); } - - /// - /// 内部渲染单个组件 - /// - private void RenderComponentInternal(FusedDesktopComponentPlacementSnapshot placement) + + private void DeselectComponent() { - if (_componentRuntimeRegistry is null || !_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var descriptor)) + if (_selectedHost is null) { - AppLogger.Warn("TransparentOverlay", $"Unknown component: {placement.ComponentId}"); return; } - - // 【修复问题3】尝试从现有窗口中获取组件实例,避免重新创建导致状态丢失 - var control = TryGetExistingControl(placement.PlacementId); - if (control is null) - { - // 如果没有现有实例,才创建新的 - control = descriptor.CreateControl( - _currentDesktopCellSize, - _timeZoneService, - _weatherDataService, - _recommendationInfoService, - _calculatorDataService, - _settingsFacade, - placement.PlacementId); - } - - RenderComponent(placement.PlacementId, control, placement.X, placement.Y, placement.Width, placement.Height); + + _selectedHost.Classes.Remove("selected"); + SetResizeHandleVisible(_selectedHost, false); + _selectedHost = null; } - - /// - /// 【修复问题3】尝试从现有的小窗口中获取组件控件实例 - /// - private Control? TryGetExistingControl(string placementId) + + private static void SetResizeHandleVisible(Border host, bool isVisible) { - try + if (host.Child is not Grid grid) { - var manager = FusedDesktopManagerServiceFactory.GetOrCreate(); - // 通过反射或公共 API 获取现有窗口中的控件 - // 这里需要 FusedDesktopManagerService 提供获取控件的方法 - // 暂时返回 null,后续需要扩展接口 - return null; + return; } - catch + + foreach (var child in grid.Children) { - return null; - } - } - - /// - /// 移除组件 - /// - public void RemoveComponent(string placementId) - { - if (_componentHosts.TryGetValue(placementId, out var host)) - { - if (Content is Canvas canvas) + if (child is Control control && control.Tag as string == ResizeHandleTag) { - canvas.Children.Remove(host); + control.IsVisible = isVisible; + control.IsHitTestVisible = isVisible; + return; } - _componentHosts.Remove(placementId); } - - _layout.ComponentPlacements.RemoveAll(p => p.PlacementId == placementId); - UpdateInteractiveRegions(); - SaveLayout(); } - - /// - /// 渲染组件(从外部传入控件) - /// - public void RenderComponent(string placementId, Control component, double x, double y, double width, double height) - { - var grid = new Grid(); - grid.Children.Add(component); - - var resizeHandle = new Border - { - Width = 24, - Height = 24, - Background = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#3B82F6")), - CornerRadius = new Avalonia.CornerRadius(12), - HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, - VerticalAlignment = Avalonia.Layout.VerticalAlignment.Bottom, - Margin = new Avalonia.Thickness(0, 0, -12, -12), - Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.BottomRightCorner), - Tag = "desktop-component-resize-handle", - IsVisible = false - }; - grid.Children.Add(resizeHandle); - - var host = new Border - { - Tag = placementId, - Width = width, - Height = height, - Background = Avalonia.Media.Brushes.Transparent, - CornerRadius = new Avalonia.CornerRadius(12), - ClipToBounds = false, // 允许把手溢出 - BorderBrush = Avalonia.Media.Brushes.Transparent, - BorderThickness = new Avalonia.Thickness(3), - Child = grid, - Classes = { "desktop-component-host" } - }; - - Canvas.SetLeft(host, x); - Canvas.SetTop(host, y); - - host.PointerPressed += OnComponentPointerPressed; - host.PointerMoved += OnInteractionPointerMoved; - host.PointerReleased += OnInteractionPointerReleased; - - // 右键上下文菜单(删除组件) - host.ContextRequested += OnComponentContextRequested; - - if (Content is Canvas canvas) - { - canvas.Children.Add(host); - } - - _componentHosts[placementId] = host; - UpdateInteractiveRegions(); - } - - // 组件右键上下文菜单(删除) + private void OnComponentContextRequested(object? sender, ContextRequestedEventArgs e) { - if (sender is not Border host || host.Tag is not string placementId) return; - - // 构建上下文菜单 + if (sender is not Border host || host.Tag is not string placementId) + { + return; + } + var deleteItem = new MenuItem { - Header = "移除组件", - Icon = new Avalonia.Controls.TextBlock { Text = "🗑" } + Header = "移除组件" }; - deleteItem.Click += (_, _) => - { - RemoveComponent(placementId); - AppLogger.Info("TransparentOverlay", $"Component removed via context menu: {placementId}"); - }; - + deleteItem.Click += (_, _) => RemoveComponent(placementId); + var menu = new ContextMenu { Items = { deleteItem } }; - - // 显示在当前控件上 menu.Open(host); e.Handled = true; } - - // 取消选中 - private void OnCanvasPointerPressed(object? sender, PointerPressedEventArgs e) - { - DeselectComponent(); - } - - // 选中组件 - private void SelectComponent(Border host) - { - if (_selectedHost == host) return; - DeselectComponent(); - - _selectedHost = host; - - // 渲染选中边框和把手 - host.BorderBrush = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#3B82F6")); - host.Classes.Add("desktop-component-host-selected"); - - if (host.Child is Grid grid) - { - foreach (var child in grid.Children) - { - if (child is Control c && c.Tag is string tg && tg == "desktop-component-resize-handle") - { - c.IsVisible = true; - break; - } - } - } - } - - private void DeselectComponent() - { - if (_selectedHost != null) - { - _selectedHost.BorderBrush = Avalonia.Media.Brushes.Transparent; - _selectedHost.Classes.Remove("desktop-component-host-selected"); - - if (_selectedHost.Child is Grid grid) - { - foreach (var child in grid.Children) - { - if (child is Control c && c.Tag is string tg && tg == "desktop-component-resize-handle") - { - c.IsVisible = false; - break; - } - } - } - } - _selectedHost = null; - } - - // 组件拖拽与缩放处理 + private void OnComponentPointerPressed(object? sender, PointerPressedEventArgs e) { - if (sender is not Border host || host.Tag is not string placementId) return; - - var point = e.GetCurrentPoint(this); - if (!point.Properties.IsLeftButtonPressed) return; - - SelectComponent(host); - - _interactionPlacementId = placementId; - _interactionHost = host; - _interactionStartPoint = e.GetPosition(this); - - // 这里必须用未吸附的原始屏幕位置计算 delta - _interactionOriginalX = Canvas.GetLeft(host); - _interactionOriginalY = Canvas.GetTop(host); - _interactionOriginalWidth = host.Width; - _interactionOriginalHeight = host.Height; - - if (e.Source is Control sourceControl && sourceControl.Tag is string tag && tag == "desktop-component-resize-handle") + if (sender is not Border host || + host.Tag is not string placementId || + !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { - _isResizing = true; - _isDragging = false; + return; + } + + var placement = _layout.ComponentPlacements.Find(p => + string.Equals(p.PlacementId, placementId, StringComparison.OrdinalIgnoreCase)); + if (placement is null || placement.IsLocked) + { + return; + } + + EnsurePlacementGridFields(placement); + SelectComponent(host); + + if (e.Source is Control sourceControl && sourceControl.Tag as string == ResizeHandleTag) + { + BeginResizeInteraction(host, placement, e); } else { - _isDragging = true; - _isResizing = false; + BeginMoveInteraction(host, placement, e); + } + + if (_editSession.IsActive) + { + e.Pointer.Capture(host); + e.Handled = true; } - - e.Pointer.Capture(host); - e.Handled = true; } - - private void OnInteractionPointerMoved(object? sender, PointerEventArgs e) + + private void BeginMoveInteraction(Border host, FusedDesktopComponentPlacementSnapshot placement, PointerPressedEventArgs e) { - if ((!_isDragging && !_isResizing) || _interactionHost is null) return; - - var currentPoint = e.GetPosition(this); - var deltaX = currentPoint.X - _interactionStartPoint.X; - var deltaY = currentPoint.Y - _interactionStartPoint.Y; - - if (_isDragging) - { - var rawX = _interactionOriginalX + deltaX; - var rawY = _interactionOriginalY + deltaY; - - var snapX = Math.Round(rawX / _currentDesktopCellSize) * _currentDesktopCellSize; - var snapY = Math.Round(rawY / _currentDesktopCellSize) * _currentDesktopCellSize; - - Canvas.SetLeft(_interactionHost, snapX); - Canvas.SetTop(_interactionHost, snapY); - } - else if (_isResizing) - { - var rawWidth = _interactionOriginalWidth + deltaX; - var rawHeight = _interactionOriginalHeight + deltaY; - - var snapWidth = Math.Round(rawWidth / _currentDesktopCellSize) * _currentDesktopCellSize; - var snapHeight = Math.Round(rawHeight / _currentDesktopCellSize) * _currentDesktopCellSize; - - // 防溢出与极小值保护 - snapWidth = Math.Max(_currentDesktopCellSize, snapWidth); - snapHeight = Math.Max(_currentDesktopCellSize, snapHeight); - - _interactionHost.Width = snapWidth; - _interactionHost.Height = snapHeight; - } - - e.Handled = true; + var pointer = e.GetPosition(this); + _interactionHost = host; + _interactionPlacementId = placement.PlacementId; + _interactionStartRow = placement.GridRow ?? 0; + _interactionStartColumn = placement.GridColumn ?? 0; + _interactionOriginalRect = DesktopPlacementMath.GetCellRect( + _gridContext.Geometry, + _interactionStartColumn, + _interactionStartRow, + placement.GridWidthCells ?? 1, + placement.GridHeightCells ?? 1); + + var pointerOffset = DesktopPlacementMath.Subtract( + pointer, + new Point(_interactionOriginalRect.X, _interactionOriginalRect.Y)); + _editSession = DesktopEditSession.CreateDraggingExisting( + placement.ComponentId, + placement.PlacementId, + pageIndex: 0, + placement.GridWidthCells ?? 1, + placement.GridHeightCells ?? 1, + pointer, + pointerOffset, + componentLibraryBounds: null); } - - private void OnInteractionPointerReleased(object? sender, PointerReleasedEventArgs e) + + private void BeginResizeInteraction(Border host, FusedDesktopComponentPlacementSnapshot placement, PointerPressedEventArgs e) { - if ((!_isDragging && !_isResizing) || _interactionHost is null || _interactionPlacementId is null) + if (_componentRuntimeRegistry is null || + !_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var descriptor)) { - _isDragging = false; - _isResizing = false; return; } - - // 更新布局中的位置与尺寸 - var placement = _layout.ComponentPlacements.Find(p => p.PlacementId == _interactionPlacementId); - if (placement is not null) + + var startSpan = ComponentPlacementRules.EnsureMinimumSize( + descriptor.Definition, + placement.GridWidthCells ?? 1, + placement.GridHeightCells ?? 1); + var minSpan = ComponentPlacementRules.EnsureMinimumSize( + descriptor.Definition, + descriptor.Definition.MinWidthCells, + descriptor.Definition.MinHeightCells); + var column = placement.GridColumn ?? 0; + var row = placement.GridRow ?? 0; + var maxWidthCells = Math.Max(startSpan.WidthCells, _gridContext.Geometry.ColumnCount - column); + var maxHeightCells = Math.Max(startSpan.HeightCells, _gridContext.Geometry.RowCount - row); + + _interactionHost = host; + _interactionPlacementId = placement.PlacementId; + _interactionStartRow = row; + _interactionStartColumn = column; + _interactionStartWidthCells = startSpan.WidthCells; + _interactionStartHeightCells = startSpan.HeightCells; + _interactionMinWidthCells = Math.Max(1, Math.Min(minSpan.WidthCells, maxWidthCells)); + _interactionMinHeightCells = Math.Max(1, Math.Min(minSpan.HeightCells, maxHeightCells)); + _interactionMaxWidthCells = Math.Max(_interactionMinWidthCells, maxWidthCells); + _interactionMaxHeightCells = Math.Max(_interactionMinHeightCells, maxHeightCells); + _interactionResizeMode = descriptor.Definition.ResizeMode; + _interactionOriginalRect = DesktopPlacementMath.GetCellRect( + _gridContext.Geometry, + column, + row, + startSpan.WidthCells, + startSpan.HeightCells); + + _editSession = DesktopEditSession.CreateResizingExisting( + placement.ComponentId, + placement.PlacementId, + pageIndex: 0, + startSpan.WidthCells, + startSpan.HeightCells, + e.GetPosition(this), + componentLibraryBounds: null) with { - placement.X = Canvas.GetLeft(_interactionHost); - placement.Y = Canvas.GetTop(_interactionHost); - placement.Width = _interactionHost.Width; - placement.Height = _interactionHost.Height; + TargetRow = row, + TargetColumn = column + }; + } + + private void OnInteractionPointerMoved(object? sender, PointerEventArgs e) + { + if (!_editSession.IsActive || _interactionHost is null) + { + return; } - + + _editSession = _editSession.WithCurrentPointer(e.GetPosition(this)); + if (_editSession.IsDraggingExisting) + { + UpdateMoveInteraction(); + } + else if (_editSession.IsResizingExisting) + { + UpdateResizeInteraction(); + } + + e.Handled = true; + } + + private void UpdateMoveInteraction() + { + if (_interactionHost is null) + { + return; + } + + var hasSnap = DesktopPlacementMath.TryGetSnappedCell( + _gridContext.Geometry, + _editSession.CurrentPointerInViewport, + _editSession.PointerOffsetInViewport, + _editSession.WidthCells, + _editSession.HeightCells, + out var column, + out var row); + if (!hasSnap) + { + return; + } + + _editSession = _editSession.WithTargetCell(row, column); + var rect = DesktopPlacementMath.GetCellRect( + _gridContext.Geometry, + column, + row, + _editSession.WidthCells, + _editSession.HeightCells); + ApplyHostRect(_interactionHost, rect); UpdateInteractiveRegions(); - SaveLayout(); - - _isDragging = false; - _isResizing = false; - _interactionPlacementId = null; - _interactionHost = null; - + } + + private void UpdateResizeInteraction() + { + if (_interactionHost is null) + { + return; + } + + var deltaX = _editSession.CurrentPointerInViewport.X - _editSession.StartPointerInViewport.X; + var deltaY = _editSession.CurrentPointerInViewport.Y - _editSession.StartPointerInViewport.Y; + int widthCells; + int heightCells; + + if (_interactionResizeMode == DesktopComponentResizeMode.Free) + { + widthCells = Math.Clamp( + (int)Math.Round(_interactionStartWidthCells + deltaX / _gridContext.Geometry.Pitch), + _interactionMinWidthCells, + _interactionMaxWidthCells); + heightCells = Math.Clamp( + (int)Math.Round(_interactionStartHeightCells + deltaY / _gridContext.Geometry.Pitch), + _interactionMinHeightCells, + _interactionMaxHeightCells); + } + else + { + var widthScale = (_interactionOriginalRect.Width + deltaX) / Math.Max(1, _interactionOriginalRect.Width); + var heightScale = (_interactionOriginalRect.Height + deltaY) / Math.Max(1, _interactionOriginalRect.Height); + var proposedScale = Math.Max(widthScale, heightScale); + if (double.IsNaN(proposedScale) || double.IsInfinity(proposedScale)) + { + proposedScale = 1; + } + + var minScale = Math.Max( + (double)_interactionMinWidthCells / Math.Max(1, _interactionStartWidthCells), + (double)_interactionMinHeightCells / Math.Max(1, _interactionStartHeightCells)); + var maxScale = Math.Min( + (double)_interactionMaxWidthCells / Math.Max(1, _interactionStartWidthCells), + (double)_interactionMaxHeightCells / Math.Max(1, _interactionStartHeightCells)); + if (maxScale < minScale) + { + maxScale = minScale; + } + + var scale = Math.Clamp(proposedScale, minScale, maxScale); + widthCells = Math.Clamp( + (int)Math.Round(_interactionStartWidthCells * scale), + _interactionMinWidthCells, + _interactionMaxWidthCells); + heightCells = Math.Clamp( + (int)Math.Round(_interactionStartHeightCells * scale), + _interactionMinHeightCells, + _interactionMaxHeightCells); + } + + _editSession = _editSession with + { + WidthCells = Math.Max(1, widthCells), + HeightCells = Math.Max(1, heightCells), + TargetRow = _interactionStartRow, + TargetColumn = _interactionStartColumn + }; + + var rect = DesktopPlacementMath.GetCellRect( + _gridContext.Geometry, + _interactionStartColumn, + _interactionStartRow, + _editSession.WidthCells, + _editSession.HeightCells); + ApplyHostRect(_interactionHost, rect); + UpdateInteractiveRegions(); + } + + private void OnInteractionPointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (!_editSession.IsActive || _interactionHost is null || _interactionPlacementId is null) + { + ResetInteraction(); + return; + } + + CompleteInteraction(); e.Pointer.Capture(null); e.Handled = true; } - - // 三指滑动处理 + + private void OnInteractionPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) + { + if (!_editSession.IsActive || _interactionHost is null) + { + return; + } + + CompleteInteraction(); + } + + private void CompleteInteraction() + { + if (_interactionPlacementId is null) + { + ResetInteraction(); + return; + } + + var placement = _layout.ComponentPlacements.Find(p => + string.Equals(p.PlacementId, _interactionPlacementId, StringComparison.OrdinalIgnoreCase)); + if (placement is not null && _editSession.HasTargetCell) + { + placement.GridRow = _editSession.TargetRow; + placement.GridColumn = _editSession.TargetColumn; + placement.GridWidthCells = Math.Max(1, _editSession.WidthCells); + placement.GridHeightCells = Math.Max(1, _editSession.HeightCells); + ApplyGridPlacementToPixelPlacement(placement); + if (_interactionHost is not null) + { + ApplyHostRect(_interactionHost, new Rect(placement.X, placement.Y, placement.Width, placement.Height)); + } + + SaveLayout(); + } + + UpdateInteractiveRegions(); + ResetInteraction(); + } + + private void ResetInteraction() + { + _editSession = default; + _interactionHost = null; + _interactionPlacementId = null; + _interactionOriginalRect = default; + _interactionStartRow = 0; + _interactionStartColumn = 0; + _interactionStartWidthCells = 0; + _interactionStartHeightCells = 0; + _interactionMinWidthCells = 0; + _interactionMinHeightCells = 0; + _interactionMaxWidthCells = 0; + _interactionMaxHeightCells = 0; + _interactionResizeMode = DesktopComponentResizeMode.Proportional; + } + + private static void ApplyHostRect(Border host, Rect rect) + { + Canvas.SetLeft(host, rect.X); + Canvas.SetTop(host, rect.Y); + host.Width = Math.Max(1, rect.Width); + host.Height = Math.Max(1, rect.Height); + if (host.Child is Grid grid && grid.Children.Count > 0 && grid.Children[0] is Control component) + { + component.Width = host.Width; + component.Height = host.Height; + } + } + protected override void OnPointerPressed(PointerPressedEventArgs e) { var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); @@ -595,26 +925,26 @@ public partial class TransparentOverlayWindow : Window base.OnPointerPressed(e); return; } - + if (!TryGetPointerPosition(e, out var pointerPos)) { base.OnPointerPressed(e); return; } - + var currentPoint = e.GetCurrentPoint(this); var pointerId = e.Pointer?.Id ?? 0; var isRightButtonPressed = currentPoint.Properties.IsRightButtonPressed; var isLeftButtonPressed = currentPoint.Properties.IsLeftButtonPressed; - + if (isLeftButtonPressed || isRightButtonPressed) { _activePointerIds.Add(pointerId); } - + var isThreeFinger = _activePointerIds.Count >= 3; var isRightDrag = isRightButtonPressed; - + if (isThreeFinger || isRightDrag) { _isSwipeActive = true; @@ -633,7 +963,7 @@ public partial class TransparentOverlayWindow : Window base.OnPointerPressed(e); } } - + protected override void OnPointerMoved(PointerEventArgs e) { if (_isSwipeActive && !IsSwipePointer(e.Pointer)) @@ -647,49 +977,49 @@ public partial class TransparentOverlayWindow : Window base.OnPointerMoved(e); return; } - + if (!TryGetPointerPosition(e, out var pointerPos)) { base.OnPointerMoved(e); return; } - + _swipeCurrentPoint = pointerPos; UpdateSwipeVelocity(pointerPos); - + var deltaX = _swipeCurrentPoint.X - _swipeStartPoint.X; var deltaY = _swipeCurrentPoint.Y - _swipeStartPoint.Y; - + if (!_isSwipeDirectionLocked) { const double activationThreshold = 14; const double horizontalBias = 1.15; var absDeltaX = Math.Abs(deltaX); var absDeltaY = Math.Abs(deltaY); - + if (absDeltaY >= activationThreshold && absDeltaY > absDeltaX * horizontalBias) { CancelSwipeInteraction(e.Pointer); base.OnPointerMoved(e); return; } - + if (absDeltaX < activationThreshold || absDeltaX <= absDeltaY * horizontalBias) { base.OnPointerMoved(e); return; } - + _isSwipeDirectionLocked = true; if (e.Pointer?.Captured != this) { e.Pointer?.Capture(this); } } - + e.Handled = true; } - + protected override void OnPointerReleased(PointerReleasedEventArgs e) { var pointerId = e.Pointer?.Id ?? 0; @@ -700,19 +1030,16 @@ public partial class TransparentOverlayWindow : Window base.OnPointerReleased(e); return; } - - if (_isSwipeActive) + + if (_isSwipeActive && EndSwipeInteraction(e.Pointer)) { - if (EndSwipeInteraction(e.Pointer)) - { - e.Handled = true; - return; - } + e.Handled = true; + return; } - + base.OnPointerReleased(e); } - + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) { var pointerId = e.Pointer?.Id ?? 0; @@ -734,10 +1061,10 @@ public partial class TransparentOverlayWindow : Window { EndSwipeInteraction(e.Pointer); } - + base.OnPointerCaptureLost(e); } - + private bool TryGetPointerPosition(PointerEventArgs e, out Point point) { try @@ -757,31 +1084,32 @@ public partial class TransparentOverlayWindow : Window return !_swipePointerId.HasValue || pointer is not null && pointer.Id == _swipePointerId.Value; } - + private void UpdateSwipeVelocity(Point currentPoint) { var now = Stopwatch.GetTimestamp(); var elapsed = Stopwatch.GetElapsedTime(_swipeLastTimestamp, now).TotalSeconds; - if (elapsed > 0) { - var dx = currentPoint.X - _swipeLastPoint.X; - _swipeVelocityX = dx / elapsed; + _swipeVelocityX = (currentPoint.X - _swipeLastPoint.X) / elapsed; } - + _swipeLastPoint = currentPoint; _swipeLastTimestamp = now; } - + private void CancelSwipeInteraction(IPointer? pointer) { - if (!_isSwipeActive) return; - + if (!_isSwipeActive) + { + return; + } + if (pointer?.Captured == this) { - pointer?.Capture(null); + pointer.Capture(null); } - + _isSwipeActive = false; _isSwipeDirectionLocked = false; _isThreeFingerOrRightDragSwipeActive = false; @@ -790,49 +1118,51 @@ public partial class TransparentOverlayWindow : Window _swipeVelocityX = 0; _swipeLastTimestamp = 0; } - + private bool EndSwipeInteraction(IPointer? pointer) { - if (!_isSwipeActive) return false; - + if (!_isSwipeActive) + { + return false; + } + var wasDirectionLocked = _isSwipeDirectionLocked; var wasThreeFingerOrRightDrag = _isThreeFingerOrRightDragSwipeActive; - + _isSwipeActive = false; _isSwipeDirectionLocked = false; _isThreeFingerOrRightDragSwipeActive = false; _activePointerIds.Clear(); _swipePointerId = null; - + if (pointer?.Captured == this) { - pointer?.Capture(null); + pointer.Capture(null); } - + _swipeLastTimestamp = 0; - + if (!wasDirectionLocked) { _swipeVelocityX = 0; return false; } - + var deltaX = _swipeCurrentPoint.X - _swipeStartPoint.X; var deltaY = _swipeCurrentPoint.Y - _swipeStartPoint.Y; var absDeltaX = Math.Abs(deltaX); - var distanceThreshold = Math.Max(48, this.Bounds.Width * 0.14); - var velocityThreshold = Math.Max(860, this.Bounds.Width * 1.08); + var distanceThreshold = Math.Max(48, Bounds.Width * 0.14); + var velocityThreshold = Math.Max(860, Bounds.Width * 1.08); var hasDistanceIntent = absDeltaX >= distanceThreshold && absDeltaX > Math.Abs(deltaY) * 1.05; var hasVelocityIntent = Math.Abs(_swipeVelocityX) >= velocityThreshold; - - // 向左滑动回到第一页 + if (wasThreeFingerOrRightDrag && deltaX < 0 && (hasDistanceIntent || hasVelocityIntent)) { RestoreMainWindowRequested?.Invoke(this, EventArgs.Empty); _swipeVelocityX = 0; return true; } - + _swipeVelocityX = 0; return hasDistanceIntent || hasVelocityIntent; } diff --git a/LanMountainDesktop/WindowsIdentity/AppxManifest.xml b/LanMountainDesktop/WindowsIdentity/AppxManifest.xml new file mode 100644 index 0000000..1e8c69d --- /dev/null +++ b/LanMountainDesktop/WindowsIdentity/AppxManifest.xml @@ -0,0 +1,42 @@ + + + + + + + LanMountainDesktop + LanMountainDesktop Team + Assets\logo_nightly.png + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/installer/LanMountainDesktop.iss b/LanMountainDesktop/installer/LanMountainDesktop.iss index b23f9be..88d61a9 100644 --- a/LanMountainDesktop/installer/LanMountainDesktop.iss +++ b/LanMountainDesktop/installer/LanMountainDesktop.iss @@ -129,11 +129,11 @@ Name: "startup"; Description: "{cm:StartupTaskDescription}"; GroupDescription: " Name: "{app}\log"; Permissions: users-modify [InstallDelete] -Type: files; Name: "{app}\LanMontainDesktop.exe" -Type: files; Name: "{app}\LanMontainDesktop.dll" -Type: files; Name: "{app}\LanMontainDesktop.deps.json" -Type: files; Name: "{app}\LanMontainDesktop.runtimeconfig.json" -Type: files; Name: "{app}\LanMontainDesktop.pdb" +Type: files; Name: "{app}\LanMountainDesktop.exe" +Type: files; Name: "{app}\LanMountainDesktop.dll" +Type: files; Name: "{app}\LanMountainDesktop.deps.json" +Type: files; Name: "{app}\LanMountainDesktop.runtimeconfig.json" +Type: files; Name: "{app}\LanMountainDesktop.pdb" [Files] Source: "{#PublishDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs @@ -146,8 +146,12 @@ Name: "{autodesktop}\{cm:AppShortcutName}"; Filename: "{app}\{#MyAppExeName}"; T Root: HKA; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#MyAppName}"; ValueData: """{app}\{#MyAppExeName}"""; Tasks: startup; Flags: uninsdeletevalue [Run] +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""if (Get-Command Add-AppxPackage -ErrorAction SilentlyContinue) {{ try {{ Add-AppxPackage -Register '{app}\WindowsIdentity\AppxManifest.xml' -ExternalLocation '{app}' -ForceApplicationShutdown -ErrorAction Stop }} catch {{ Write-Host $_.Exception.Message }} }}"""; Flags: runhidden Filename: "{app}\{#MyAppExeName}"; Parameters: "--launch-source postinstall"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent +[UninstallRun] +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""if (Get-Command Get-AppxPackage -ErrorAction SilentlyContinue) {{ Get-AppxPackage -Name 'LanMountainDesktop.NotificationIdentity' | Remove-AppxPackage -ErrorAction SilentlyContinue }}"""; Flags: runhidden + [Code] const UninstallRegSubkey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppRegistryId}_is1'; @@ -391,10 +395,6 @@ begin begin Params := Params + ' /SILENT'; end; - if WizardVerySilent then - begin - Params := Params + ' /VERYSILENT'; - end; { 重启安装程序并退出当前实例 } if Exec(ExpandConstant('{srcexe}'), Params, '', SW_SHOWNORMAL, ewNoWait, ResultCode) then diff --git a/LanMountainDesktop/plugins/PluginLoader.cs b/LanMountainDesktop/plugins/PluginLoader.cs index 61c0946..503cbed 100644 --- a/LanMountainDesktop/plugins/PluginLoader.cs +++ b/LanMountainDesktop/plugins/PluginLoader.cs @@ -335,7 +335,9 @@ public sealed class PluginLoader RegisterHostService(services, hostServices); RegisterHostService(services, hostServices); RegisterHostService(services, hostServices); + // Legacy compatibility only. Normal plugin appearance snapshots come from IMaterialColorService. RegisterHostService(services, hostServices); + RegisterHostService(services, hostServices); RegisterHostService(services, hostServices); return services; @@ -343,21 +345,21 @@ public sealed class PluginLoader private static PluginAppearanceSnapshot BuildAppearanceSnapshot(IServiceProvider? hostServices) { - var defaultSnapshot = new PluginAppearanceSnapshot( - CornerRadiusTokens: new PluginCornerRadiusTokens(6, 12, 14, 20, 28, 32, 36, 24), - ThemeVariant: "Unknown"); - - if (hostServices?.GetService(typeof(IAppearanceThemeService)) is not IAppearanceThemeService appearanceThemeService) - { - return defaultSnapshot; - } + var defaultSnapshot = CreateDefaultAppearanceSnapshot(); try { - var hostSnapshot = appearanceThemeService.GetCurrent(); - return new PluginAppearanceSnapshot( - CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(hostSnapshot.CornerRadiusTokens), - ThemeVariant: hostSnapshot.IsNightMode ? "Dark" : "Light"); + if (TryBuildAppearanceSnapshotFromMaterialColorService(hostServices, out var snapshot)) + { + return snapshot; + } + + if (TryBuildCompatibilityAppearanceSnapshotFromAppearanceThemeService(hostServices, out snapshot)) + { + return snapshot; + } + + return defaultSnapshot; } catch (Exception ex) { @@ -366,6 +368,41 @@ public sealed class PluginLoader } } + private static bool TryBuildAppearanceSnapshotFromMaterialColorService( + IServiceProvider? hostServices, + out PluginAppearanceSnapshot snapshot) + { + snapshot = default!; + if (hostServices?.GetService(typeof(IMaterialColorService)) is not IMaterialColorService materialColorService) + { + return false; + } + + snapshot = PluginAppearanceSnapshotMapper.FromMaterialColorSnapshot(materialColorService.GetMaterialColorSnapshot()); + return true; + } + + private static bool TryBuildCompatibilityAppearanceSnapshotFromAppearanceThemeService( + IServiceProvider? hostServices, + out PluginAppearanceSnapshot snapshot) + { + snapshot = default!; + if (hostServices?.GetService(typeof(IAppearanceThemeService)) is not IAppearanceThemeService appearanceThemeService) + { + return false; + } + + snapshot = PluginAppearanceSnapshotMapper.FromCompatibilityAppearanceSnapshot(appearanceThemeService.GetCurrent()); + return true; + } + + private static PluginAppearanceSnapshot CreateDefaultAppearanceSnapshot() + { + return new PluginAppearanceSnapshot( + CornerRadiusTokens: new PluginCornerRadiusTokens(6, 12, 14, 20, 28, 32, 36, 24), + ThemeVariant: "Unknown"); + } + private static void RegisterHostService(IServiceCollection services, IServiceProvider? hostServices) where TService : class { diff --git a/LanMountainDesktop/plugins/PluginRuntimeService.cs b/LanMountainDesktop/plugins/PluginRuntimeService.cs index 911cdf1..397fa74 100644 --- a/LanMountainDesktop/plugins/PluginRuntimeService.cs +++ b/LanMountainDesktop/plugins/PluginRuntimeService.cs @@ -33,6 +33,7 @@ public sealed class PluginRuntimeService : IDisposable private readonly ISettingsFacadeService _settingsFacade; private readonly SettingsCatalogService _settingsCatalogService; private readonly PublicIpcHostService? _publicIpcHostService; + private readonly IMaterialColorService _materialColorService; private readonly List _loadedPlugins = []; private readonly List _loadResults = []; private readonly List _catalog = []; @@ -51,6 +52,7 @@ public sealed class PluginRuntimeService : IDisposable _packageManager = new PluginRuntimePackageManager(this); _settingsFacade = settingsFacade ?? new SettingsFacadeService(); _publicIpcHostService = publicIpcHostService; + _materialColorService = HostMaterialColorProvider.GetOrCreate(); _settingsCatalogService = _settingsFacade.Catalog as SettingsCatalogService ?? new SettingsCatalogService(); if (_settingsFacade is SettingsFacadeService concreteFacade) @@ -67,6 +69,7 @@ public sealed class PluginRuntimeService : IDisposable _publicIpcHostService); _loaderOptions = CreateOptions(); _loader = new PluginLoader(_loaderOptions); + _materialColorService.MaterialColorChanged += OnMaterialColorChanged; } public string PluginsDirectory { get; } @@ -423,6 +426,7 @@ public sealed class PluginRuntimeService : IDisposable public void Dispose() { + _materialColorService.MaterialColorChanged -= OnMaterialColorChanged; UnloadInstalledPlugins(); _sharedContractManager.Dispose(); if (_settingsFacade is IDisposable disposable && !ReferenceEquals(_settingsFacade, HostSettingsFacadeProvider.GetOrCreate())) @@ -431,6 +435,32 @@ public sealed class PluginRuntimeService : IDisposable } } + private void OnMaterialColorChanged(object? sender, MaterialColorSnapshot snapshot) + { + _ = sender; + + var pluginSnapshot = PluginAppearanceSnapshotMapper.FromMaterialColorSnapshot(snapshot); + var changedProperties = new[] + { + AppearanceProperty.ThemeVariant, + AppearanceProperty.AccentColor, + AppearanceProperty.Wallpaper, + AppearanceProperty.SystemMaterialMode, + AppearanceProperty.ColorSource, + AppearanceProperty.ColorRoles, + AppearanceProperty.MaterialSurfaces, + AppearanceProperty.WallpaperSeedCandidates + }; + + foreach (var loadedPlugin in _loadedPlugins) + { + if (loadedPlugin.RuntimeContext.Appearance is PluginAppearanceContext appearanceContext) + { + appearanceContext.UpdateSnapshot(pluginSnapshot, changedProperties); + } + } + } + private void UnloadInstalledPlugins() { for (var i = _loadedPlugins.Count - 1; i >= 0; i--) @@ -1016,6 +1046,7 @@ public sealed class PluginRuntimeService : IDisposable private readonly ISettingsService _settingsService; private readonly ISettingsCatalog _settingsCatalog; private readonly IAppearanceThemeService _appearanceThemeService; + private readonly IMaterialColorService _materialColorService; private readonly IExternalIpcNotificationPublisher? _externalIpcNotificationPublisher; public PluginHostServiceProvider( @@ -1034,6 +1065,7 @@ public sealed class PluginRuntimeService : IDisposable _settingsService = settingsService; _settingsCatalog = settingsCatalog; _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate(); + _materialColorService = HostMaterialColorProvider.GetOrCreate(); _externalIpcNotificationPublisher = externalIpcNotificationPublisher; } @@ -1071,9 +1103,15 @@ public sealed class PluginRuntimeService : IDisposable if (serviceType == typeof(IAppearanceThemeService)) { + // Compatibility-only. Plugin appearance snapshots are still sourced from the material pipeline. return _appearanceThemeService; } + if (serviceType == typeof(IMaterialColorService)) + { + return _materialColorService; + } + if (serviceType == typeof(IExternalIpcNotificationPublisher)) { return _externalIpcNotificationPublisher; diff --git a/LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 b/LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 new file mode 100644 index 0000000..b6d0fa2 --- /dev/null +++ b/LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 @@ -0,0 +1,203 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$PublishDir, + + [Parameter(Mandatory = $true)] + [string]$RuntimeIdentifier, + + [switch]$KeepSymbols, + + [switch]$AssertClean +) + +$ErrorActionPreference = "Stop" + +function Format-Size { + param([long]$Bytes) + + if ($Bytes -ge 1GB) { + return "{0:N2} GB" -f ($Bytes / 1GB) + } + + if ($Bytes -ge 1MB) { + return "{0:N2} MB" -f ($Bytes / 1MB) + } + + return "{0:N2} KB" -f ($Bytes / 1KB) +} + +function Get-DirectorySize { + param([Parameter(Mandatory = $true)][string]$Path) + + $sum = (Get-ChildItem -LiteralPath $Path -Recurse -File -ErrorAction SilentlyContinue | + Measure-Object -Property Length -Sum).Sum + if ($null -eq $sum) { + return 0 + } + + return [long]$sum +} + +function Get-RelativePathCompat { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Path + ) + + $rootPath = [System.IO.Path]::GetFullPath($Root) + if (-not $rootPath.EndsWith([System.IO.Path]::DirectorySeparatorChar)) { + $rootPath += [System.IO.Path]::DirectorySeparatorChar + } + + $targetPath = [System.IO.Path]::GetFullPath($Path) + $rootUri = [System.Uri]::new($rootPath) + $targetUri = [System.Uri]::new($targetPath) + return [System.Uri]::UnescapeDataString($rootUri.MakeRelativeUri($targetUri).ToString()).Replace('/', [System.IO.Path]::DirectorySeparatorChar) +} + +function Write-PayloadAudit { + param([Parameter(Mandatory = $true)][string]$Root) + + $files = @(Get-ChildItem -LiteralPath $Root -Recurse -File -ErrorAction SilentlyContinue) + $totalBytes = ($files | Measure-Object -Property Length -Sum).Sum + if ($null -eq $totalBytes) { + $totalBytes = 0 + } + + Write-Host "Publish payload audit" + Write-Host " Root: $Root" + Write-Host " Files: $($files.Count)" + Write-Host " Total: $(Format-Size -Bytes $totalBytes)" + + Write-Host "Largest files:" + $files | + Sort-Object Length -Descending | + Select-Object -First 30 | + ForEach-Object { + $relative = Get-RelativePathCompat -Root $Root -Path $_.FullName + Write-Host (" {0,10} {1}" -f (Format-Size -Bytes $_.Length), $relative) + } + + Write-Host "By extension:" + $extensionGroups = @($files | Group-Object Extension) + $extensionRows = foreach ($group in $extensionGroups) { + $bytes = ($group.Group | Measure-Object -Property Length -Sum).Sum + if ($null -eq $bytes) { + $bytes = 0 + } + + [PSCustomObject]@{ + Extension = if ([string]::IsNullOrWhiteSpace($group.Name)) { "" } else { $group.Name } + Count = $group.Count + Bytes = [long]$bytes + } + } + + foreach ($row in @($extensionRows | Sort-Object Bytes -Descending)) { + Write-Host (" {0,10} {1,5} {2}" -f (Format-Size -Bytes $row.Bytes), $row.Count, $row.Extension) + } + + $runtimeRoots = @(Get-ChildItem -LiteralPath $Root -Recurse -Directory -Filter "runtimes" -ErrorAction SilentlyContinue) + if ($runtimeRoots.Count -gt 0) { + Write-Host "Runtime directories:" + foreach ($runtimeRoot in $runtimeRoots) { + Get-ChildItem -LiteralPath $runtimeRoot.FullName -Directory -ErrorAction SilentlyContinue | + Sort-Object Name | + ForEach-Object { + $relative = Get-RelativePathCompat -Root $Root -Path $_.FullName + Write-Host (" {0,10} {1}" -f (Format-Size -Bytes (Get-DirectorySize -Path $_.FullName)), $relative) + } + } + } +} + +function Remove-PdbFiles { + param([Parameter(Mandatory = $true)][string]$Root) + + if ($KeepSymbols) { + Write-Host "Keeping PDB files because -KeepSymbols was specified." + return + } + + $pdbFiles = @(Get-ChildItem -LiteralPath $Root -Recurse -File -Filter "*.pdb" -ErrorAction SilentlyContinue) + foreach ($file in $pdbFiles) { + Remove-Item -LiteralPath $file.FullName -Force -ErrorAction Stop + } + + Write-Host "Removed PDB files: $($pdbFiles.Count)" +} + +function Remove-NonTargetRuntimeDirectories { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Rid + ) + + if ($Rid -notlike "win-*") { + return + } + + $runtimeRoots = @(Get-ChildItem -LiteralPath $Root -Recurse -Directory -Filter "runtimes" -ErrorAction SilentlyContinue) + $removed = 0 + foreach ($runtimeRoot in $runtimeRoots) { + Get-ChildItem -LiteralPath $runtimeRoot.FullName -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -ne $Rid } | + ForEach-Object { + Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction Stop + $removed++ + } + } + + Write-Host "Removed non-target runtime directories: $removed" +} + +function Assert-WindowsPayloadClean { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Rid + ) + + if ($Rid -notlike "win-*") { + return + } + + $violations = [System.Collections.Generic.List[string]]::new() + $forbiddenExtensions = @(".pdb", ".so", ".dylib", ".a") + + Get-ChildItem -LiteralPath $Root -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $forbiddenExtensions -contains $_.Extension.ToLowerInvariant() } | + ForEach-Object { + $violations.Add((Get-RelativePathCompat -Root $Root -Path $_.FullName)) + } + + Get-ChildItem -LiteralPath $Root -Recurse -Directory -Filter "runtimes" -ErrorAction SilentlyContinue | + ForEach-Object { + Get-ChildItem -LiteralPath $_.FullName -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -ne $Rid } | + ForEach-Object { + $violations.Add((Get-RelativePathCompat -Root $Root -Path $_.FullName)) + } + } + + if ($violations.Count -gt 0) { + $sample = ($violations | Select-Object -First 50) -join [Environment]::NewLine + throw "Windows publish payload contains forbidden files or runtime directories for ${Rid}:$([Environment]::NewLine)$sample" + } + + Write-Host "Windows payload guard passed for $Rid." +} + +$resolvedPublishDir = [System.IO.Path]::GetFullPath($PublishDir) +if (-not (Test-Path -LiteralPath $resolvedPublishDir)) { + throw "Publish directory not found: $resolvedPublishDir" +} + +Write-Host "Optimizing publish payload for $RuntimeIdentifier..." +Remove-PdbFiles -Root $resolvedPublishDir +Remove-NonTargetRuntimeDirectories -Root $resolvedPublishDir -Rid $RuntimeIdentifier +Write-PayloadAudit -Root $resolvedPublishDir + +if ($AssertClean) { + Assert-WindowsPayloadClean -Root $resolvedPublishDir -Rid $RuntimeIdentifier +} diff --git a/LanMountainDesktop/scripts/package.ps1 b/LanMountainDesktop/scripts/package.ps1 index e01064c..e56c6ad 100644 --- a/LanMountainDesktop/scripts/package.ps1 +++ b/LanMountainDesktop/scripts/package.ps1 @@ -199,6 +199,60 @@ function Add-LinuxDesktopAssets { Copy-Item -LiteralPath $installScriptSource -Destination (Join-Path $PublishedDirectory "install.sh") -Force } +function Invoke-PublishPayloadOptimization { + param( + [Parameter(Mandatory = $true)][string]$PublishedDirectory, + [Parameter(Mandatory = $true)][string]$Rid + ) + + $optimizer = Join-Path $scriptRoot "Optimize-PublishPayload.ps1" + if (-not (Test-Path -LiteralPath $optimizer)) { + throw "Publish payload optimizer is missing: $optimizer" + } + + & $optimizer ` + -PublishDir $PublishedDirectory ` + -RuntimeIdentifier $Rid ` + -AssertClean ` + -KeepSymbols:$KeepSymbols + if ($LASTEXITCODE -ne 0) { + throw "Publish payload optimization failed with exit code $LASTEXITCODE." + } +} + +function Publish-AirAppHostPayload { + param( + [Parameter(Mandatory = $true)][string]$PublishedDirectory, + [Parameter(Mandatory = $true)][string]$Rid, + [Parameter(Mandatory = $true)][string]$VersionValue + ) + + $airAppHostProject = Join-Path $repoRoot "..\LanMountainDesktop.AirAppHost\LanMountainDesktop.AirAppHost.csproj" + $airAppHostProject = Resolve-ExistingPath -PathValue $airAppHostProject + Write-Host "Publishing AirAppHost payload..." + $airPublishArgs = @( + "publish", + $airAppHostProject, + "-c", $Configuration, + "-r", $Rid, + "--self-contained", "false", + "-p:PublishSingleFile=false", + "-p:PublishTrimmed=false", + "-p:PublishReadyToRun=false", + "-p:DebugType=None", + "-p:DebugSymbols=false", + "-p:BuildingAirAppHost=true", + "-p:SkipAirAppHostBuild=true", + "-p:Version=$VersionValue", + "-o", $PublishedDirectory + ) + + & dotnet @airPublishArgs + if ($LASTEXITCODE -ne 0) { + throw "AirAppHost publish failed with exit code $LASTEXITCODE." + } +} + $scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path $repoRoot = Resolve-ExistingPath -PathValue (Join-Path $scriptRoot "..") @@ -231,6 +285,7 @@ $publishArgs = @( "-p:PublishTrimmed=false", "-p:DebugType=None", "-p:DebugSymbols=false", + "-p:SkipAirAppHostBuild=true", "-p:Version=$Version", "-o", $PublishDir ) @@ -240,6 +295,7 @@ if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed with exit code $LASTEXITCODE." } +Publish-AirAppHostPayload -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier -VersionValue $Version Remove-LibVlcForOtherArch -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier Remove-LegacyOutputArtifacts -TargetDirectory $PublishDir @@ -247,11 +303,7 @@ if ($RuntimeIdentifier -like "linux-*") { Add-LinuxDesktopAssets -PublishedDirectory $PublishDir -RepoRoot $repoRoot } -if (-not $KeepSymbols) { - Get-ChildItem -Path $PublishDir -Recurse -File -Filter "*.pdb" | ForEach-Object { - [System.IO.File]::Delete($_.FullName) - } -} +Invoke-PublishPayloadOptimization -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier if (Is-WindowsRuntimeIdentifier -Rid $RuntimeIdentifier) { if (-not $InstallerOutputDir) { diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..696b4d2 --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildOptions.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildOptions.cs index c86bb0d..12f492d 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildOptions.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuildOptions.cs @@ -11,4 +11,6 @@ public sealed record PlondsDeltaBuildOptions( string? BaselineVersion = null, string? BaselineTag = null, string? BaselinePayloadZip = null, - bool IsFullPayload = false); + bool IsFullPayload = false, + string? StaticOutputRoot = null, + string? UpdateBaseUrl = null); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs index 4164dcf..ec7dc6b 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsDeltaBuilder.cs @@ -53,7 +53,13 @@ public sealed class PlondsDeltaBuilder ? new Dictionary(StringComparer.OrdinalIgnoreCase) : PayloadUtilities.ScanDirectory(baselineExtractRoot); var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot); - var fileEntries = BuildFileEntries(previousManifest, currentManifest, objectsRoot); + var updateBaseUrl = string.IsNullOrWhiteSpace(options.UpdateBaseUrl) + ? null + : options.UpdateBaseUrl.TrimEnd('/'); + var repoBaseUrl = string.IsNullOrWhiteSpace(updateBaseUrl) + ? null + : $"{updateBaseUrl}/repo/sha256"; + var fileEntries = BuildFileEntries(previousManifest, currentManifest, objectsRoot, repoBaseUrl); var updateAssetName = $"update-{options.Platform}.zip"; var fileMapAssetName = $"plonds-filemap-{options.Platform}.json"; @@ -76,6 +82,7 @@ public sealed class PlondsDeltaBuilder ["isFullPayload"] = useFullPayload ? "true" : "false" }; + var generatedAt = DateTimeOffset.UtcNow; var component = new ComponentDocument( Name: "app", Version: options.CurrentVersion, @@ -95,7 +102,7 @@ public sealed class PlondsDeltaBuilder Platform: options.Platform, Arch: PayloadUtilities.ResolveArch(options.Platform), Channel: options.Channel, - GeneratedAt: DateTimeOffset.UtcNow, + GeneratedAt: generatedAt, Metadata: metadata, Components: [component], Files: fileEntries); @@ -103,6 +110,20 @@ public sealed class PlondsDeltaBuilder PayloadUtilities.WriteJson(fileMapPath, fileMap); _signer.SignFile(fileMapPath, options.PrivateKeyPath, fileMapSignaturePath); + if (!string.IsNullOrWhiteSpace(options.StaticOutputRoot) && !string.IsNullOrWhiteSpace(updateBaseUrl)) + { + WriteStaticLayout( + options, + component, + objectsRoot, + distributionId, + fileMapPath, + fileMapSignaturePath, + Path.GetFullPath(options.StaticOutputRoot), + updateBaseUrl, + generatedAt); + } + var summary = new PlondsReleasePlatformEntry( Platform: options.Platform, DistributionId: distributionId, @@ -135,7 +156,8 @@ public sealed class PlondsDeltaBuilder private static List BuildFileEntries( IReadOnlyDictionary previousManifest, IReadOnlyDictionary currentManifest, - string objectsRoot) + string objectsRoot, + string? repoBaseUrl) { var result = new List(); @@ -152,12 +174,16 @@ public sealed class PlondsDeltaBuilder Size: current.Size, ObjectPath: null, ObjectKey: null, + ObjectUrl: null, Metadata: null)); continue; } var action = previousManifest.ContainsKey(path) ? "replace" : "add"; var objectPath = PayloadUtilities.CopyObject(current.FullPath, objectsRoot, current.Sha256); + var objectUrl = string.IsNullOrWhiteSpace(repoBaseUrl) + ? null + : $"{repoBaseUrl.TrimEnd('/')}/{objectPath}"; var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["mode"] = "file-object" @@ -174,6 +200,7 @@ public sealed class PlondsDeltaBuilder Size: current.Size, ObjectPath: objectPath, ObjectKey: objectPath, + ObjectUrl: objectUrl, Metadata: metadata)); } @@ -191,12 +218,87 @@ public sealed class PlondsDeltaBuilder Size: 0, ObjectPath: null, ObjectKey: null, + ObjectUrl: null, Metadata: null)); } return result; } + private static void WriteStaticLayout( + PlondsDeltaBuildOptions options, + ComponentDocument component, + string objectsRoot, + string distributionId, + string fileMapPath, + string fileMapSignaturePath, + string staticOutputRoot, + string updateBaseUrl, + DateTimeOffset generatedAt) + { + var repoRoot = Path.Combine(staticOutputRoot, "repo", "sha256"); + var manifestRoot = Path.Combine(staticOutputRoot, "manifests", distributionId); + var distributionRoot = Path.Combine(staticOutputRoot, "meta", "distributions"); + var channelRoot = Path.Combine(staticOutputRoot, "meta", "channels", options.Channel, options.Platform); + + CopyDirectory(objectsRoot, repoRoot); + Directory.CreateDirectory(manifestRoot); + File.Copy(fileMapPath, Path.Combine(manifestRoot, "plonds-filemap.json"), overwrite: true); + File.Copy(fileMapSignaturePath, Path.Combine(manifestRoot, "plonds-filemap.json.sig"), overwrite: true); + + var fileMapUrl = $"{updateBaseUrl}/manifests/{Uri.EscapeDataString(distributionId)}/plonds-filemap.json"; + var distribution = new DistributionDocument( + DistributionId: distributionId, + Version: options.CurrentVersion, + SourceVersion: options.BaselineVersion ?? "0.0.0", + Channel: options.Channel, + Platform: options.Platform, + Arch: PayloadUtilities.ResolveArch(options.Platform), + PublishedAt: generatedAt, + FileMapUrl: fileMapUrl, + FileMapSignatureUrl: fileMapUrl + ".sig", + Components: [component], + InstallerMirrors: [], + Capabilities: ["file-object"], + Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["protocol"] = "PLONDS", + ["releaseTag"] = options.CurrentTag, + ["baselineTag"] = options.BaselineTag ?? string.Empty, + ["baselineVersion"] = options.BaselineVersion ?? "0.0.0", + ["targetVersion"] = options.CurrentVersion, + ["isFullPayload"] = options.IsFullPayload ? "true" : "false" + }); + + var latest = new LatestPointerDocument( + DistributionId: distributionId, + Version: options.CurrentVersion, + Channel: options.Channel, + Platform: options.Platform, + PublishedAt: generatedAt); + + PayloadUtilities.WriteJson(Path.Combine(distributionRoot, distributionId + ".json"), distribution); + PayloadUtilities.WriteJson(Path.Combine(channelRoot, "latest.json"), latest); + } + + private static void CopyDirectory(string sourceDir, string destinationDir) + { + Directory.CreateDirectory(destinationDir); + foreach (var directory in Directory.EnumerateDirectories(sourceDir, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(sourceDir, directory); + Directory.CreateDirectory(Path.Combine(destinationDir, relativePath)); + } + + foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(sourceDir, file); + var destinationPath = Path.Combine(destinationDir, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + File.Copy(file, destinationPath, overwrite: true); + } + } + private sealed record FileMapDocument( string FormatVersion, string DistributionId, @@ -224,5 +326,35 @@ public sealed class PlondsDeltaBuilder long Size, string? ObjectPath, string? ObjectKey, + string? ObjectUrl, IReadOnlyDictionary? Metadata); + + private sealed record DistributionDocument( + string DistributionId, + string Version, + string SourceVersion, + string Channel, + string Platform, + string Arch, + DateTimeOffset PublishedAt, + string FileMapUrl, + string FileMapSignatureUrl, + IReadOnlyList Components, + IReadOnlyList InstallerMirrors, + IReadOnlyList Capabilities, + IReadOnlyDictionary? Metadata); + + private sealed record LatestPointerDocument( + string DistributionId, + string Version, + string Channel, + string Platform, + DateTimeOffset PublishedAt); + + private sealed record InstallerMirrorDocument( + string Platform, + string? Url, + string? FileName, + string? Sha256, + long Size); } diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs index 05bd276..871f47d 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs @@ -135,7 +135,9 @@ internal static class PlondsCli BaselineVersion: Get(options, "baseline-version"), BaselineTag: Get(options, "baseline-tag"), BaselinePayloadZip: Get(options, "baseline-zip"), - IsFullPayload: bool.TryParse(Get(options, "is-full-payload", "false"), out var isFullPayload) && isFullPayload)); + IsFullPayload: bool.TryParse(Get(options, "is-full-payload", "false"), out var isFullPayload) && isFullPayload, + StaticOutputRoot: Get(options, "static-output-dir"), + UpdateBaseUrl: Get(options, "update-base-url"))); Console.WriteLine($"Built PLONDS delta for {result.Platform}: {result.UpdateArchivePath}"); Console.WriteLine(result.FileMapPath); @@ -211,7 +213,7 @@ internal static class PlondsCli { Console.WriteLine("PLONDS Tool"); Console.WriteLine(" pack-payload --source-dir --output-zip "); - Console.WriteLine(" build-delta --platform --current-version --current-tag --current-zip --output-dir --private-key [--baseline-tag ] [--baseline-version ] [--baseline-zip ] [--is-full-payload]"); + Console.WriteLine(" build-delta --platform --current-version --current-tag --current-zip --output-dir --private-key [--baseline-tag ] [--baseline-version ] [--baseline-zip ] [--is-full-payload] [--static-output-dir ] [--update-base-url ]"); Console.WriteLine(" build-index --release-tag --version --platform-summaries-dir --output-dir --private-key [--channel ]"); Console.WriteLine(" build-ddss --release-tag --assets-dir --output-dir --private-key --repository [--s3-base-url ]"); Console.WriteLine(" sign --manifest --private-key [--output ]"); diff --git a/SECURITY_AUDIT_REPORT.md b/SECURITY_AUDIT_REPORT.md new file mode 100644 index 0000000..a6ab7bd --- /dev/null +++ b/SECURITY_AUDIT_REPORT.md @@ -0,0 +1,196 @@ +# 安全审计报告 + +**项目**: LanMountainDesktop +**审计日期**: 2026-05-11 +**审计范围**: 整体代码库安全性评估 +**审计方法**: 自动化静态代码分析 + 架构审查 + +--- + +## 执行摘要 + +本次审计对 LanMountainDesktop 代码库进行了系统性安全评估,重点关注认证与访问控制、注入向量、外部交互以及敏感数据处理等高风险攻击面。 + +**审计结论**: 发现 **4 个已确认的中等及以上严重度漏洞**,建议立即修复。 + +--- + +## 已确认漏洞 + +### 漏洞 #1 - PostHog API Key 硬编码(高严重度) + +| 属性 | 详情 | +|------|------| +| **严重度** | 高 | +| **CWE** | CWE-798 - 使用硬编码凭证 | +| **位置** | `LanMountainDesktop/Services/PostHogUsageTelemetryService.cs:14` | +| **攻击者画像** | 源代码仓库的任何访问者(包括外部攻击者通过代码泄露或供应链攻击) | +| **可控输入** | 无(静态硬编码密钥) | + +**代码路径**: +```csharp +// PostHogUsageTelemetryService.cs:14 +private const string PostHogApiKey = "phc_bhQZvKDDfsEdLT6kkRFvrWMT8Pc5aCGGsnxoc5ijSf9"; +``` + +**影响**: +- 攻击者可能滥用此 API Key 向 PostHog 项目发送伪造遥测数据 +- 可能导致遥测数据污染或服务滥用 +- API Key 暴露在公开仓库中,任何人都能获取 + +**修复建议**: +```csharp +private const string PostHogApiKey = Environment.GetEnvironmentVariable("POSTHOG_API_KEY") + ?? throw new InvalidOperationException("PostHog API key not configured."); +``` + +--- + +### 漏洞 #2 - Sentry DSN 硬编码(高严重度) + +| 属性 | 详情 | +|------|------| +| **严重度** | 高 | +| **CWE** | CWE-798 - 使用硬编码凭证 | +| **位置** | `LanMountainDesktop/Services/SentryCrashTelemetryService.cs:15` | +| **攻击者画像** | 源代码仓库的任何访问者 | +| **可控输入** | 无(静态硬编码密钥) | + +**代码路径**: +```csharp +// SentryCrashTelemetryService.cs:15 +private const string SentryDsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504"; +``` + +**影响**: +- Sentry DSN 等同于项目的访问凭证 +- 攻击者可利用此 DSN 向项目发送伪造崩溃报告 +- 可能导致崩溃数据污染或敏感信息收集 + +**修复建议**: +```csharp +private const string SentryDsn = Environment.GetEnvironmentVariable("SENTRY_DSN") + ?? throw new InvalidOperationException("Sentry DSN not configured."); +``` + +--- + +### 漏洞 #3 - 小米天气 API 签名密钥硬编码(高严重度) + +| 属性 | 详情 | +|------|------| +| **严重度** | 高 | +| **CWE** | CWE-798 - 使用硬编码凭证 | +| **位置** | `LanMountainDesktop/Services/XiaomiWeatherService.cs:25` | +| **攻击者画像** | 源代码仓库的任何访问者 | +| **可控输入** | 无(静态硬编码密钥) | + +**代码路径**: +```csharp +// XiaomiWeatherService.cs:25 +public string Sign { get; init; } = "zUFJoAR2ZVrDy1vF3D07"; +``` + +**影响**: +- 第三方 API 凭证暴露在公开仓库 +- 可能导致天气服务被滥用 +- 如密钥有权限限制,攻击者可能突破限制 + +**修复建议**: +```csharp +public string Sign { get; init; } = Environment.GetEnvironmentVariable("XIAOMI_WEATHER_SIGN") ?? ""; +``` + +--- + +### 漏洞 #4 - Sentry PII 收集配置(中等严重度) + +| 属性 | 详情 | +|------|------| +| **严重度** | 中等 | +| **CWE** | CWE-359 - 个人身份信息(PII)意外暴露 | +| **位置** | `LanMountainDesktop/Services/SentryCrashTelemetryService.cs:212` | +| **攻击者画像** | Sentry 后端管理员、内部威胁或数据泄露事件 | +| **可控输入** | 用户环境的机器名、用户名等系统信息 | +| **利用路径** | `程序启动 → TelemetryIdentityService.Initialize()` → 遥测数据上报 | + +**代码路径**: +```csharp +// SentryCrashTelemetryService.cs:212 +options.SendDefaultPii = true; +``` + +**影响**: +- `SendDefaultPii = true` 配置会收集和上报用户 IP 地址 +- 可能违反隐私法规(如 GDPR)要求 +- 在崩溃报告中可能暴露用户敏感信息 + +**修复建议**: +```csharp +options.SendDefaultPii = false; // 默认收集 PII +options.SendDefaultPii = TelemetryEnvironmentInfo.IsTelemetryPiiAllowed(); // 或根据用户同意状态动态设置 +``` + +--- + +## 未发现漏洞的区域 + +经过系统性审计,以下区域未发现中等及以上严重度的已确认漏洞: + +### 认证与访问控制 +- 单实例服务实现正确(使用互斥体) +- IPC 通信使用命名管道,无明显认证绕过风险 +- 插件隔离使用独立进程边界 + +### 注入向量 +- SQLite 使用参数化查询,无 SQL 注入风险 +- JSON 反序列化使用强类型上下文,无反序列化漏洞 +- 文件路径操作使用 `Path.Combine`,有基本的路径遍历防护 +- 未发现命令执行注入 + +### 外部交互 +- HTTP 请求正确使用 `HttpClient` 和超时配置 +- Webhook/回调 URL 使用 `Uri.EscapeDataString` 编码 +- 下载服务验证目标路径,无路径遍历风险 + +### 敏感数据处理 +- 数据库本地存储,使用 WAL 模式 +- 设置数据通过 JSON 序列化存储在用户目录 +- 日志文件路径正确隔离在应用数据目录 + +--- + +## 架构安全评估 + +| 组件 | 安全评级 | 说明 | +|------|----------|------| +| 插件系统 | 良好 | 使用独立进程隔离 | +| IPC 通信 | 良好 | 命名管道通信,进程边界隔离 | +| 更新系统 | 良好 | 支持签名验证 | +| 遥测系统 | **需改进** | 存在硬编码凭证和 PII 配置问题 | +| 数据存储 | 良好 | 使用标准加密实践 | + +--- + +## 修复优先级 + +| 优先级 | 漏洞 | 预计工作量 | +|--------|------|------------| +| P0 - 紧急 | #1 PostHog API Key | 低 | +| P0 - 紧急 | #2 Sentry DSN | 低 | +| P0 - 紧急 | #3 Xiaomi Weather Sign | 低 | +| P1 - 高 | #4 SendDefaultPii | 低 | + +--- + +## 建议的安全改进 + +1. **实施密钥管理**: 使用环境变量或密钥管理服务(如 Azure Key Vault、AWS Secrets Manager)存储所有 API 凭证 +2. **添加密钥扫描**: 在 CI/CD 流程中集成 secrets scanning(如 GitGuardian、trufflehog) +3. **隐私合规审查**: 确认遥测数据收集符合当地隐私法规要求 +4. **代码审计**: 建议进行定期安全审计 + +--- + +*报告生成工具: 自动安全审计系统* +*审计方法: 静态代码分析 + 架构审查* diff --git a/ThirdParty/DotNetCampus.InkCanvas/DotNetCampus.AvaloniaInkCanvas.Avalonia12.csproj b/ThirdParty/DotNetCampus.InkCanvas/DotNetCampus.AvaloniaInkCanvas.Avalonia12.csproj new file mode 100644 index 0000000..5a2cca4 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/DotNetCampus.AvaloniaInkCanvas.Avalonia12.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + true + DotNetCampus.AvaloniaInkCanvas + DotNetCampus.Inking + false + + + + + + + + + diff --git a/ThirdParty/DotNetCampus.InkCanvas/README.md b/ThirdParty/DotNetCampus.InkCanvas/README.md new file mode 100644 index 0000000..d5f5adc --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/README.md @@ -0,0 +1,17 @@ +# DotNetCampus.InkCanvas Avalonia 12 Compatibility Fork + +This source is vendored from `dotnet-campus/DotNetCampus.InkCanvas` at commit +`e4383cadc3ae206dd96f5b72ba889a007ebc44fa`, matching the +`DotNetCampus.AvaloniaInkCanvas` 1.0.1 NuGet package previously used by the app. + +The local project keeps the assembly name `DotNetCampus.AvaloniaInkCanvas` so the +host code can continue using the existing namespaces and APIs. + +Local compatibility changes: + +- Replace Avalonia 11 `Visual.VisualRoot` render scaling access with + `TopLevel.GetTopLevel(this)?.RenderScaling`. +- Reference Avalonia 12 packages from a local project instead of the + Avalonia 11-targeted NuGet package. +- Import the Avalonia 12 optional feature extension namespace for Skia custom + drawing operations. diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/API/InkCanvas.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/API/InkCanvas.cs new file mode 100644 index 0000000..547153b --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/API/InkCanvas.cs @@ -0,0 +1,282 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +using DotNetCampus.Inking.Contexts; +using DotNetCampus.Inking.Erasing; +using DotNetCampus.Inking.Interactives; +using DotNetCampus.Inking.Primitive; +using DotNetCampus.Inking.Utils; + +namespace DotNetCampus.Inking; + +public class InkCanvas : Control +{ + public InkCanvas() + { + var avaloniaSkiaInkCanvas = new AvaloniaSkiaInkCanvas() + { + IsHitTestVisible = false + }; + AddChild(avaloniaSkiaInkCanvas); + AvaloniaSkiaInkCanvas = avaloniaSkiaInkCanvas; + HorizontalAlignment = HorizontalAlignment.Stretch; + VerticalAlignment = VerticalAlignment.Stretch; + } + + public InkCanvasEditingMode EditingMode + { + get => _editingMode; + set + { + if (IsDuringInput) + { + throw new InvalidOperationException($"EditingMode should not be switched during the input process."); + } + + _editingMode = value; + EditingModeChanged?.Invoke(this, EventArgs.Empty); + } + } + + public event EventHandler? EditingModeChanged; + + public IReadOnlyList Strokes => AvaloniaSkiaInkCanvas.StaticStrokeList; + + private InkCanvasEditingMode _editingMode = InkCanvasEditingMode.Ink; + + /// + /// 为 Avalonia 实现的基于 Skia 的 InkCanvas 笔迹画布 + /// + public AvaloniaSkiaInkCanvas AvaloniaSkiaInkCanvas { get; } + + private AvaloniaSkiaInkCanvasEraserMode EraserMode => AvaloniaSkiaInkCanvas.EraserMode; + + /// + public event EventHandler? StrokeCollected + { + add => AvaloniaSkiaInkCanvas.StrokeCollected += value; + remove => AvaloniaSkiaInkCanvas.StrokeCollected -= value; + } + + public event EventHandler? StrokeErased + { + add => EraserMode.ErasingCompleted += value; + remove => EraserMode.ErasingCompleted -= value; + } + + #region Input + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + if (EditingMode == InkCanvasEditingMode.None) + { + return; + } + + var args = ToArgs(e); + _inputDictionary[e.Pointer.Id] = new InputInfo(args.StylusPoint); + + if (EditingMode == InkCanvasEditingMode.Ink) + { + if (!IsDuringInput) + { + AvaloniaSkiaInkCanvas.WritingStart(); + } + + AvaloniaSkiaInkCanvas.WritingDown(in args); + } + else if (EditingMode == InkCanvasEditingMode.EraseByPoint) + { + EraserMode.EraserDown(in args); + } + + base.OnPointerPressed(e); + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + if (EditingMode == InkCanvasEditingMode.None) + { + return; + } + + if (!_inputDictionary.TryGetValue(e.Pointer.Id, out var inputInfo)) + { + // Mouse? Not pressed yet. + return; + } + + var args = ToArgs(e, inputInfo); + inputInfo.LastStylusPoint = args.StylusPoint; + + if (EditingMode == InkCanvasEditingMode.Ink) + { + AvaloniaSkiaInkCanvas.WritingMove(in args); + } + else if (EditingMode == InkCanvasEditingMode.EraseByPoint) + { + EraserMode.EraserMove(in args); + } + + base.OnPointerMoved(e); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + if (EditingMode == InkCanvasEditingMode.None) + { + return; + } + + if (!_inputDictionary.Remove(e.Pointer.Id, out var inputInfo)) + { + // Mouse? Not pressed yet. + return; + } + + var args = ToArgs(e); + inputInfo.LastStylusPoint = args.StylusPoint; + + if (EditingMode == InkCanvasEditingMode.Ink) + { + AvaloniaSkiaInkCanvas.WritingUp(in args); + + if (!IsDuringInput) + { + AvaloniaSkiaInkCanvas.WritingCompleted(); + } + } + else if (EditingMode == InkCanvasEditingMode.EraseByPoint) + { + EraserMode.EraserUp(in args); + } + + base.OnPointerReleased(e); + } + + class InputInfo + { + public InputInfo(InkStylusPoint lastStylusPoint) + { + LastStylusPoint = lastStylusPoint; + } + + public InkStylusPoint LastStylusPoint { get; set; } + } + + private readonly Dictionary _inputDictionary = []; + private bool IsDuringInput => _inputDictionary.Count != 0; + + private InkingModeInputArgs ToArgs(PointerEventArgs args, InputInfo? inputInfo = null) + { + PointerPoint currentPoint = args.GetCurrentPoint(AvaloniaSkiaInkCanvas); + var inkStylusPoint = ToInkStylusPoint(currentPoint); + + IReadOnlyList? stylusPointList = null; + var list = args.GetIntermediatePoints(AvaloniaSkiaInkCanvas); + if (list.Count > 1) + { + stylusPointList = list.Select(ToInkStylusPoint) + .ToList(); + } + + return new InkingModeInputArgs(args.Pointer.Id, inkStylusPoint, args.Timestamp) + { + StylusPointList = stylusPointList, + }; + + InkStylusPoint ToInkStylusPoint(PointerPoint point) + { + var pressure = EnsurePressure(currentPoint.Properties.Pressure); + var contactRect = currentPoint.Properties.ContactRect; + var width = contactRect.Width; + var height = contactRect.Height; + + if (inputInfo is not null) + { + if (width == 0 && inputInfo.LastStylusPoint.Width is { } lastWidth) + { + width = lastWidth; + } + + if (height == 0 && inputInfo.LastStylusPoint.Height is { } lastHeight) + { + height = lastHeight; + } + } + + var stylusPoint = new InkStylusPoint(currentPoint.Position.ToPoint2D(), pressure) + { + Width = width != 0 ? width : null, + Height = height != 0 ? height : null, + }; + + return stylusPoint; + } + + float EnsurePressure(float pressure) + { + // 这是一个修复补丁。在 Linux X11 上,如果前后两个点的压力是相同的,则后点将不会报告压力,此时 Avalonia 上将使用默认压力值 0.5 来填充压力值 + // 为了避免压力值抖动,将压力值修正为上一个点的压力值 + const float defaultPressure = InkStylusPoint.DefaultPressure; + if (inputInfo != null && (pressure == 0 || Math.Abs(pressure - defaultPressure) < 0.00001)) + { + return inputInfo.LastStylusPoint.Pressure; + } + + return pressure; + } + } + + #endregion + + internal void AddChild(Control childControl) + { + LogicalChildren.Add(childControl); + VisualChildren.Add(childControl); + } + + internal void RemoveChild(Control childControl) + { + LogicalChildren.Remove(childControl); + VisualChildren.Remove(childControl); + } + + protected override Size MeasureCore(Size availableSize) + { + var width = availableSize.Width; + var height = availableSize.Height; + + if (double.IsInfinity(width)) + { + width = 0; + } + + if (double.IsInfinity(height)) + { + height = 0; + } + + base.MeasureCore(availableSize); + return new Size(width, height); + } + + protected override Size ArrangeOverride(Size finalSize) + { + var size = base.ArrangeOverride(finalSize); + + return size; + } + + public override void Render(DrawingContext context) + { + // to enable hit testing + context.DrawRectangle(Brushes.Transparent, null, new Rect(new Point(), Bounds.Size)); + } + + +} + diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/API/InkCanvasEditingMode.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/API/InkCanvasEditingMode.cs new file mode 100644 index 0000000..2c04456 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/API/InkCanvasEditingMode.cs @@ -0,0 +1,8 @@ +namespace DotNetCampus.Inking; + +public enum InkCanvasEditingMode +{ + None = 0, + Ink = 1, + EraseByPoint = 5, +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Caching/InkBitmapCache.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Caching/InkBitmapCache.cs new file mode 100644 index 0000000..86db73f --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Caching/InkBitmapCache.cs @@ -0,0 +1,464 @@ +using DotNetCampus.Inking.Contexts; +using DotNetCampus.Logging; +using DotNetCampus.Numerics; +using DotNetCampus.Numerics.Geometry; + +using SkiaSharp; +using System.Runtime.CompilerServices; +using DotNetCampus.Inking.Utils; + +namespace DotNetCampus.Inking.Caching; + +/// +/// 辅助绘制笔迹的位图缓存。 +/// +internal sealed class InkBitmapCache : IDisposable +{ + private readonly object _lock = new(); + + private readonly AvaloniaSkiaInkCanvasSettings _settings; + private readonly int _uiThreadId; + private int _renderThreadId; + private bool _useCacheOnNextRender; + private InkBitmapCacheContext? _cacheContext; + + /// + /// 笔迹缓存数据。 + /// + /// + /// isValid 由 UI 线程写入,渲染线程读取。
+ /// CachedData 由渲染线程写入,渲染线程读取。 + ///
+ private volatile ThreadSafeInkBitmapCachedData _threadSafeCachedData = new(false, null); + + /// + /// 笔迹缓存上下文信息。 + /// + /// + /// 此字段由 UI 线程写入,由渲染线程读取。 + /// + private InkBitmapCacheContext? CacheContext + { + get => _cacheContext.VerifyOnRenderThread(_renderThreadId); + set => _cacheContext = value.VerifyOnUIThread(_uiThreadId); + } + + /// + /// 将此值设置为 以在下次绘制时使用位图缓存;反之,设置为 将在下帧不使用位图缓存。 + /// + /// + /// 如果希望立即使用位图缓存,请在将此值设置为 后立即调用 方法刷新渲染。 + /// + public bool UseCacheOnNextRender + { + get => _useCacheOnNextRender; + set + { + if (Equals(_useCacheOnNextRender, value)) + { + return; + } + + _useCacheOnNextRender = value.VerifyOnUIThread(_uiThreadId); + Log.Info($"[Ink][InkBitmapCache] 将在下一帧{(value ? "开启" : "关闭")}笔迹位图缓存。"); + InvalidateCache(); + } + } + + /// + /// 创建 的新实例。 + /// + /// 包含位图缓存的设置。 + public InkBitmapCache(AvaloniaSkiaInkCanvasSettings settings) + { + _uiThreadId = Environment.CurrentManagedThreadId; + _settings = settings; + } + + /// + /// 更新缓存所需的上下文信息,包括 DPI(画板到屏幕的缩放)和元素变换(元素到画板的变换矩阵)。 + /// + /// 画板相对于屏幕物理像素的缩放比例。 + /// 画板可见区域的边界(画板坐标系)。 + /// 笔迹变换到画板应使用的变换矩阵。 + public void UpdateCacheContext(double dpi, BoundingBox2D visibleBounds, SimilarityTransformation2D transformFromInkToRoot) + { + this.VerifyOnUIThread(_uiThreadId); + CacheContext = (dpi, visibleBounds, transformFromInkToRoot); + InvalidateCache(); + } + + public void Dispose() => InvalidateCache(); + + /// + /// 使缓存失效,如果下次绘制时缓存启用(),则会重新生成缓存。 + /// + /// + /// 注意,虽然使用此方法可以使缓存失效,以便下次绘制时会使用缓存;但如果不调用 方法更新上下文信息,则生成的新缓存依旧会使用旧的信息。
+ /// 在此情况下,生成的新缓存图片在参数(清晰度、旋转方向、缩放等)上会跟旧缓存图片完全相同,只是新增和擦除的笔迹会在新的缓存图片上体现出来。
+ /// 如果你希望按照新的笔迹旋转方向、缩放等生成匹配此时应有清晰度的缓存图片,你应该调用 而不是本方法。那个方法本质上也会使缓存失效的。 + ///
+ internal void InvalidateCache() + { + this.VerifyOnUIThread(_uiThreadId); + lock (_lock) + { + var (_, data) = _threadSafeCachedData; + _threadSafeCachedData = new ThreadSafeInkBitmapCachedData(false, data); + } + } + + /// + /// 以位图缓存的形式画出笔迹。 + /// + /// 笔迹的路径。 + /// 要绘制到的画布。 + /// 用于绘制的画笔。 + /// 在使用绘制的路径前,请先调用 方法。 + internal void DrawBitmap(in ReadOnlySpan paths, SKCanvas canvas, SKPaint skPaint) + { + _renderThreadId = Environment.CurrentManagedThreadId; + + if (paths.Length is 0 || paths.HasNoPoints()) + { + // 没有任何路径,不需要绘制。 + // 所有的路径都没有点,无法被绘制。 + return; + } + + var context = CacheContext ?? throw new InvalidOperationException("在使用绘制的路径前,请先调用 UpdateCacheContext 方法。"); + + var threadSafeCachedData = _threadSafeCachedData; + var (isValid, cachedData) = threadSafeCachedData; + if (!isValid || cachedData is not { } data) + { + cachedData?.Dispose(); + // 如果缓存已失效,则重新生成缓存。 + data = CreateBitmapData(paths, skPaint, context, _settings); + lock (_lock) + { + _threadSafeCachedData = new ThreadSafeInkBitmapCachedData(true, data); + } + } + + DrawBitmapData(canvas, context, data); + } + + /// + /// 创建笔迹的位图缓存。 + /// + /// 笔迹的路径。 + /// 用于绘制的画笔。 + /// 缓存所用的上下文信息。 + /// 位图缓存的设置。 + /// 缓存的位图数据。 + private static InkBitmapCachedData CreateBitmapData(in ReadOnlySpan paths, SKPaint skPaint, in InkBitmapCacheContext context, AvaloniaSkiaInkCanvasSettings settings) + { + // 将原始的笔迹数据(元素坐标系)转换为画板坐标系,并求取其边界。 + var inkBounds = paths.GetTransformedBounds(context.TransformFromInkToRoot); + // 生成一个伪的可视区域,使其有画板区域的 41 倍大,以便生成全景笔迹图作为背景位图(将镂空前景位图)。放心最终不会有这么大的,一来会根据实际笔迹区域裁剪,二来会根据 settings.MaxBitmapCacheSize 限制位图大小。 + var backVisibleBounds = context.VisibleBounds.Inflate(context.VisibleBounds.Width * 20, context.VisibleBounds.Height * 20); + // 生成一个真实的可视区域,使其在画板的周围扩大 100 个单位,以便生成高清前景位图。 + var foreVisibleBounds = context.VisibleBounds.Inflate(100); + + // 如果笔迹区域比可视区域小,那么全景笔迹图就是高清前景图。不再需要背景图了。 + var isForeBitmapEnough = foreVisibleBounds.Contains(inkBounds); + + // 创建低清背景位图,使其显示全景笔迹。 + var backBitmap = isForeBitmapEnough + ? null + : CreateBitmapCore(paths, skPaint, context, settings, inkBounds, backVisibleBounds, foreVisibleBounds); + + // 创建高清可见区域位图,使其高质量显示局部笔迹。 + var foreBitmap = CreateBitmapCore(paths, skPaint, context, settings, inkBounds, foreVisibleBounds); + + // 记录日志并返回数据。 + var data = new InkBitmapCachedData(backBitmap, foreBitmap); + LogBitmapCached(data, inkBounds); + return data; + } + + /// + /// 创建一张位图,使其显示笔迹的一部分。 + /// + /// 要画的笔迹的路径。 + /// 用于绘制的画笔。 + /// 位图缓存所用的上下文信息。 + /// 位图缓存的设置。 + /// 所有笔迹的边界(画板坐标系)。 + /// 画板的可视区域(画板坐标系)。 + /// 如果需要镂空一个区域,则传入此区域的边界(画板坐标系)。 + /// 创建的位图数据。 + /// + /// 如果笔迹完全移出了可视区域,则不会创建位图,返回
+ ///
+ private static InkQualityBitmapData? CreateBitmapCore( + in ReadOnlySpan paths, SKPaint skPaint, + in InkBitmapCacheContext context, AvaloniaSkiaInkCanvasSettings settings, + BoundingBox2D inkBounds, BoundingBox2D visibleBounds, BoundingBox2D? clipBounds = null) + { + // 笔迹和可视区域的交集。使用此交集可以避免笔迹区域没那么大时生成过大的位图。 + var intersectedBounds = inkBounds.Intersect(visibleBounds); + if (clipBounds is { } cb1) + { + intersectedBounds = intersectedBounds.Exclude(cb1); + } + if (intersectedBounds.IsEmpty) + { + // 如果交集为空,则不需要绘制。 + return null; + } + // 根据用户的设置,决定要显示多清晰的一张位图缓存。 + var (bitmapWidth, bitmapHeight, scalingRootToBitmap, quality) = + CalculateBestBitmapScaling(intersectedBounds.Width, intersectedBounds.Height, context.DpiScaling, settings.MaxBitmapCacheSize); + + // 创建位图。 + var bitmap = new SKBitmap(bitmapWidth, bitmapHeight, SKColorType.Bgra8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + + // 将位图坐标系转换为笔迹坐标系,这样后面画笔迹时可以直接使用其数据。 + canvas.Scale((float) scalingRootToBitmap, (float) scalingRootToBitmap); + canvas.Translate(-(float) intersectedBounds.MinX, -(float) intersectedBounds.MinY); + if (clipBounds is { } cb2) + { + // 如果有镂空需求,则镂空一个区域(画板坐标系)。 + // 当我们期望用一个全景背景图和高清前景图拼接时,背景图就需要将前景图的区域镂空。 + canvas.ClipRect(cb2, SKClipOperation.Difference); + } + var skMatrix = context.TransformFromInkToRoot.ToSkMatrix(); + canvas.Concat(ref skMatrix); + + // 使用笔迹坐标系绘制笔迹。 + foreach (var c in paths) + { + skPaint.Color = c.Color; + canvas.DrawPath(c.Path, skPaint); + } + + // 返回位图数据。 + return new(bitmap, intersectedBounds, scalingRootToBitmap, quality); + } + + /// + /// 画出参数 中指定的多张缓存位图。 + /// + /// 要绘制到的画布。 + /// 缓存所用的上下文信息。 + /// 缓存的位图数据。 + private static void DrawBitmapData(SKCanvas canvas, in InkBitmapCacheContext context, in InkBitmapCachedData data) + { + // 绘制镂空的笔迹全景图。 + if (data.BackBitmap is { } backBitmap) + { + DrawBitmapCore(canvas, in context, in backBitmap); + } + + // 绘制高清的笔迹局部图。 + if (data.ForeBitmap is { } foreBitmap) + { + DrawBitmapCore(canvas, in context, in foreBitmap); + } + } + + /// + /// 画出参数 中指定的单张缓存位图。 + /// + /// 要绘制到的画布。 + /// 缓存所用的上下文信息。 + /// 缓存的位图数据。 + private static void DrawBitmapCore(SKCanvas canvas, in InkBitmapCacheContext context, in InkQualityBitmapData data) + { + var numericMatrix = canvas.TotalMatrix.ToSimilarityTransformation(); + var transform = SimilarityTransformation2D.Identity + .Scale(1 / data.ScalingRootToBitmap) + .Translate(new Vector2D(data.InkBounds.MinX, data.InkBounds.MinY)) + .Apply(context.TransformFromInkToRoot.Inverse()) + .Apply(numericMatrix); + + canvas.Save(); + canvas.SetMatrix(transform.ToSkMatrix()); + try + { + using var paint = new SKPaint(); + paint.IsAntialias = true; + paint.FilterQuality = SKFilterQuality.High; + canvas.DrawBitmap(data.Bitmap, 0, 0, paint); + } + finally + { + canvas.Restore(); + } + } + + /// + /// 计算最佳的位图缩放比例,以便在显示时不会产生过大的位图。 + /// + /// 笔迹在笔迹元素坐标系中的宽度。 + /// 笔迹在笔迹元素坐标系中的高度。 + /// 画板到屏幕像素的缩放比例。 + /// 缓存位图的最大像素数(避免过大导致性能问题)。 + /// + /// 在最佳缩放比例下的:
+ /// BitmapWidth:位图的宽度(像素)。
+ /// BitmapHeight:位图的高度(像素)。
+ /// Quality:位图的清晰度,即位图的像素数与最大像素数的比例的平方根。
+ /// ScalingInkToBitmap:笔迹/元素坐标系内的长度乘以此值,可以得到位图缓存中的像素长度。 + ///
+ private static (int BitmapWidth, int BitmapHeight, double ScalingRootToBitmap, double Quality) CalculateBestBitmapScaling( + double inkWidth, double inkHeight, double dpiScaling, int maxBitmapPixelCount) + { + // 为防止计算溢出(当宽高足够大时),下面的像素数计算我们尽量使用 double 类型。 + var desiredBitmapPixelWidth = (int) Math.Round(inkWidth * dpiScaling); + var desiredBitmapPixelHeight = (int) Math.Round(inkHeight * dpiScaling); + var totalBitmapPixelCount = desiredBitmapPixelWidth * (double) desiredBitmapPixelHeight; + + // 如果原尺寸显示笔迹也不会产生太大的位图,则直接使用原尺寸。 + if (totalBitmapPixelCount <= maxBitmapPixelCount) + { + return (desiredBitmapPixelWidth, desiredBitmapPixelHeight, dpiScaling, 1d); + } + + // 如果原尺寸显示笔迹会产生过大的位图,则缩小位图,以更低清晰度来显示。 + var quality = Math.Sqrt(maxBitmapPixelCount / totalBitmapPixelCount); + return ( + (int) Math.Round(desiredBitmapPixelWidth * quality), + (int) Math.Round(desiredBitmapPixelHeight * quality), + dpiScaling * quality, + quality); + } + + /// + /// 记录一条日志,表示笔迹位图缓存已更新。 + /// + /// + /// + private static void LogBitmapCached(InkBitmapCachedData data, BoundingBox2D inkBounds) => Log.Info(data switch + { + ({ } bb, { } fb) => + $"[Ink][InkBitmapCache] 笔迹位图缓存更新。笔迹 {inkBounds.Width}×{inkBounds.Height},全景图 {bb.Bitmap.Width}×{bb.Bitmap.Height}(清晰度 {bb.Quality}),前景图 {fb.Bitmap.Width}×{fb.Bitmap.Height}(清晰度 {fb.Quality})", + ({ } bb, null) => + $"[Ink][InkBitmapCache] 笔迹位图缓存更新。笔迹 {inkBounds.Width}×{inkBounds.Height},全景图 {bb.Bitmap.Width}×{bb.Bitmap.Height}(清晰度 {bb.Quality})", + (null, { } fb) => + $"[Ink][InkBitmapCache] 笔迹位图缓存更新。笔迹 {inkBounds.Width}×{inkBounds.Height},前景图 {fb.Bitmap.Width}×{fb.Bitmap.Height}(清晰度 {fb.Quality})", + _ => + $"[Ink][InkBitmapCache] 笔迹位图缓存无需更新。笔迹 {inkBounds.Width}×{inkBounds.Height},不在可视区域内。", + }); + + private record ThreadSafeInkBitmapCachedData(bool IsValid, InkBitmapCachedData? CachedData); +} + +/// +/// 包含一些用于 的扩展方法。 +/// +file static class Extensions +{ + internal static T VerifyOnUIThread(this T field, int threadId, [CallerMemberName] string callerMemberName = "") + { + if (Environment.CurrentManagedThreadId != threadId) + { + throw new InvalidOperationException($"成员 {callerMemberName} 只能在 UI 线程访问。"); + } + return field; + } + + internal static T VerifyOnRenderThread(this T field, int threadId, [CallerMemberName] string callerMemberName = "") + { + if (Environment.CurrentManagedThreadId != threadId) + { + throw new InvalidOperationException($"成员 {callerMemberName} 只能在渲染线程访问。"); + } + return field; + } + + internal static BoundingBox2D GetTransformedBounds(this in ReadOnlySpan paths, SimilarityTransformation2D transform) + { + var bounds = paths[0].Path.GetTransformedBounds(transform); + for (var i = 1; i < paths.Length; i++) + { + var path = paths[i].Path; + if (path.Points.Length > 0) + { + bounds.Union(path.GetTransformedBounds(transform)); + } + } + return BoundingBox2D.Create(bounds.Left, bounds.Top, bounds.Right, bounds.Bottom); + } + + private static SKRect GetTransformedBounds(this SKPath path, SimilarityTransformation2D transform) + { + using var newPath = new SKPath(path); + newPath.Transform(transform.ToSkMatrix()); + return newPath.Bounds; + } + + internal static void ClipRect(this SKCanvas canvas, BoundingBox2D clipBounds, SKClipOperation operation = SKClipOperation.Intersect) + { + canvas.ClipRect(new SKRect( + (float) clipBounds.MinX, + (float) clipBounds.MinY, + (float) clipBounds.MaxX, + (float) clipBounds.MaxY + ), operation); + } + + /// + /// 已知一个矩形边界,排除另一个矩形边界;返回排除后异形图形的边界。 + /// + /// 原始边界。 + /// 要排除掉的边界。 + /// 排除后的边界。 + internal static BoundingBox2D Exclude(this BoundingBox2D bounds, BoundingBox2D excludeBounds) + { + var includeTopLeft = excludeBounds.Contains(new Point2D(bounds.MinX, bounds.MinY)); + var includeTopRight = excludeBounds.Contains(new Point2D(bounds.MaxX, bounds.MinY)); + var includeBottomRight = excludeBounds.Contains(new Point2D(bounds.MaxX, bounds.MaxY)); + var includeBottomLeft = excludeBounds.Contains(new Point2D(bounds.MinX, bounds.MaxY)); + + if (includeTopLeft && includeTopRight && includeBottomRight && includeBottomLeft) + { + return BoundingBox2D.Empty; + } + + if (includeTopLeft && includeBottomLeft) + { + return BoundingBox2D.Create(excludeBounds.MaxX, bounds.MinY, bounds.MaxX, bounds.MaxY); + } + + if (includeTopLeft && includeTopRight) + { + return BoundingBox2D.Create(bounds.MinX, excludeBounds.MaxY, bounds.MaxX, bounds.MaxY); + } + + if (includeTopRight && includeBottomRight) + { + return BoundingBox2D.Create(bounds.MinX, bounds.MinY, excludeBounds.MinX, bounds.MaxY); + } + + if (includeBottomRight && includeBottomLeft) + { + return BoundingBox2D.Create(bounds.MinX, bounds.MinY, bounds.MaxX, excludeBounds.MinY); + } + + return bounds; + } + + /// + /// 判断所有路径是否都没有点。(这种路径没有宽高,无法被绘制。) + /// + /// 要检查的路径。 + /// 如果所有路径都没有点,则返回 ;否则返回 + public static bool HasNoPoints(this in ReadOnlySpan paths) + { + var allZeroPoints = true; + for (var i = 0; i < paths.Length; i++) + { + var path = paths[i]; + if (path.Path.Points.Length is not 0) + { + allZeroPoints = false; + break; + } + } + return allZeroPoints; + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Caching/InkBitmapCacheContext.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Caching/InkBitmapCacheContext.cs new file mode 100644 index 0000000..58069f0 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Caching/InkBitmapCacheContext.cs @@ -0,0 +1,46 @@ +using DotNetCampus.Numerics.Geometry; + +using SkiaSharp; + +namespace DotNetCampus.Inking.Caching; + +/// +/// 为 的缓存提供上下文信息。 +/// +/// 画板到屏幕像素的缩放量。 +/// 画板可见区域的边界 +/// 笔迹变换到画板(要求没有额外变换)的变换矩阵。 +internal readonly record struct InkBitmapCacheContext(double DpiScaling, BoundingBox2D VisibleBounds, SimilarityTransformation2D TransformFromInkToRoot) +{ + public static implicit operator InkBitmapCacheContext((double ScalingFromRootToDevice, BoundingBox2D VisibleBounds, SimilarityTransformation2D TransformFromInkToRoot) tuple) + => new(tuple.ScalingFromRootToDevice, tuple.VisibleBounds, tuple.TransformFromInkToRoot); +} + +/// +/// 为 的缓存提供数据。 +/// +/// 低清背景位图,显示全景笔迹。(如果笔迹本身不大,则此全景笔迹位图是 。) +/// 高清可见区域位图,高质量显示局部笔迹。(如果笔迹本身不大,则此高清位图很有可能就是全景笔迹图。如果笔迹完全移出了可视区域,则此高清位图是 。) +internal readonly record struct InkBitmapCachedData(InkQualityBitmapData? BackBitmap, InkQualityBitmapData? ForeBitmap) : IDisposable +{ + public void Dispose() + { + BackBitmap?.Dispose(); + ForeBitmap?.Dispose(); + } +} + +/// +/// 为 的缓存提供数据。 +/// +/// 位图。 +/// 本次缓存的位图所使用的笔迹边界。 +/// 画板到位图的缩放量。 +/// 位图的清晰度,即位图的像素数与最大像素数的比例的平方根。 +internal readonly record struct InkQualityBitmapData(SKBitmap Bitmap, BoundingBox2D InkBounds, double ScalingRootToBitmap, double Quality) : IDisposable +{ + public void Dispose() + { + Bitmap.Dispose(); + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Contexts/AvaloniaSkiaInkCanvasContext.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Contexts/AvaloniaSkiaInkCanvasContext.cs new file mode 100644 index 0000000..8c525b1 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Contexts/AvaloniaSkiaInkCanvasContext.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace DotNetCampus.Inking.Contexts; + +internal class AvaloniaSkiaInkCanvasContext +{ + private bool _isUsingBitmapCache; + + /// + /// 获取可以由业务指定的笔迹设置。 + /// + public AvaloniaSkiaInkCanvasSettings Settings { get; } = new(); + + /// + /// 如果指定为 ,则立即使用位图缓存替代真实的笔迹。
+ /// 否则,位图缓存将不会工作,而使用真实的笔迹渲染。 + ///
+ /// + /// 在合适的时机切换真实的笔迹和位图缓存,可能可以提高性能。
+ /// 例如,在书写时,使用真实的笔迹可以提高书写性能;在漫游时,使用位图缓存可以提高漫游性能。 + ///
+ public bool ShouldUseBitmapCache => Settings.IsBitmapCacheEnabled && _isUsingBitmapCache; + + /// + /// 指定是否立即使用位图缓存替代真实的笔迹,或是使用真实的笔迹渲染。 + /// + /// + /// 指定为 ,则立即使用位图缓存替代真实的笔迹;
+ /// 指定为 ,则位图缓存将不会工作,而使用真实的笔迹渲染。 + /// + /// + /// 注意,使用此方法和修改用户设置方法 的本质不同为: + /// + /// 此方法决定在不同的程序状态下是否应使用位图缓存,例如书写时不应开启缓存,而漫游时应该开启缓存; + /// 而修改用户设置则是一个开关选项,仅决定在上述合适的状态下是否应使用位图缓存。当遇到不合适的程序状态时位图缓存依然不会生效。 + /// + /// 所以,正确的做法是: + /// + /// 应用开发者在应用程序初始化时设置用户设置 + /// 框架开发者在实现位图缓存时,在适当的时机开启和关闭位图缓存,例如书写时关闭,漫游时开启。 + /// + /// + public void UseBitmapCache(bool useBitmapCache) + { + _isUsingBitmapCache = useBitmapCache; + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Contexts/AvaloniaSkiaInkCanvasSettings.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Contexts/AvaloniaSkiaInkCanvasSettings.cs new file mode 100644 index 0000000..3e40525 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Contexts/AvaloniaSkiaInkCanvasSettings.cs @@ -0,0 +1,110 @@ +using Avalonia; +using DotNetCampus.Inking.Erasing; +using DotNetCampus.Inking.StrokeRenderers; +using SkiaSharp; + +namespace DotNetCampus.Inking.Contexts; + +/// +/// 笔迹画布设置 +/// +public class AvaloniaSkiaInkCanvasSettings +{ + /// + /// 笔迹粗细。 + /// + public float InkThickness { get; set; } = DefaultInkThickness; + + /// + /// 默认的笔迹粗细 + /// + public static float DefaultInkThickness => 10; + + /// + /// 笔迹颜色。 + /// + public SKColor InkColor { get; set; } = DefaultInkColor; + + public static SKColor DefaultInkColor => SKColors.Red; + + /// + /// 橡皮擦尺寸,可以在业务层,在手势橡皮擦过程中更改 + /// + public Size EraserSize { get; set; } = DefaultEraserSize; + + /// + /// 默认的橡皮擦尺寸 + /// + public static Size DefaultEraserSize => new Size(48, + 72); + + /// + /// 橡皮擦界面的创建器,默认为空使用默认的橡皮擦界面创建器 + /// + public IEraserViewCreator? EraserViewCreator { get; set; } + + /// + /// 将触摸尺寸当成橡皮擦尺寸,即橡皮擦大小不完全跟随 尺寸,而是会根据 的触摸大小决定 + /// + public bool EnableStylusSizeAsEraserSize { get; set; } = true; + + /// + /// 橡皮擦是否可以一直按照触摸尺寸修改橡皮擦尺寸。属于演示效果较好,实际使用效果差。仅当 为 true 时此属性才有效。为 false 时,将在超过 时间,设置为最后的触摸面积固定大小,即只允许在开始擦的时候根据触摸面积修改大小,之后将固定大小 + /// + public bool CanEraserAlwaysFollowsTouchSize { set; get; } = false; + + /// + /// 是否允许使用位图缓存在合适的时候替代真实的笔迹以提升部分场景下的笔迹性能。 + /// + /// + /// 如果指定为 ,则书写模块会在合适的时机切换真实的笔迹和位图缓存,可能可以提高性能。
+ /// 如果指定为 ,则位图缓存将不会工作,将一直使用真实的笔迹渲染。 + ///
+ public bool IsBitmapCacheEnabled { get; set; } = true; + + /// + /// 橡皮擦可以根据触摸面积尺寸修改橡皮擦大小的时间。如果 为 true 则此属性无效。仅当 为 true 时此属性才有效 + /// + public TimeSpan EraserCanResizeDuringTimeSpan { set; get; } = TimeSpan.FromMilliseconds(600); + + /// + /// 是否锁定最小橡皮擦尺寸,即要求橡皮擦尺寸最小为 大小 + /// + public bool LockMinEraserSize { init; get; } = true; + + /// + /// 最小橡皮擦尺寸。仅当 为 true 时生效 + /// + public Size MinEraserSize { init; get; } = DefaultEraserSize; + + /// + /// 最大橡皮擦尺寸。理论上用不着,只是用来限制尺寸而已 + /// + public Size MaxEraserSize { init; get; } = new Size(600, 600); + + /// + /// 当使用位图缓存()时,最大的位图缓存大小。单位为像素。 + /// + public int MaxBitmapCacheSize { get; set; } = + // 兆芯上似乎 1920×1080 都扛不住??? + OperatingSystem.IsLinux() ? 1080 * 1080 : + // 主流 Intel/AMD 上目前看性能还行。 + OperatingSystem.IsWindows() ? 2560 * 1440 : + // 默认随便给个值吧。 + 1920 * 1080; + + /// + /// 是否需要重新创建笔迹点,采用平滑滤波算法 + /// + public bool ShouldReCreatePoint { get; set; } + + /// + /// 笔迹渲染器。为空将使用默认的笔迹渲染器 + /// + public ISkiaInkStrokeRenderer? InkStrokeRenderer { get; set; } + + /// + /// 设置或获取是否需要忽略压感 + /// + public bool IgnorePressure { get; set; } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Contexts/AvaloniaSkiaInkCanvasStrokeCollectedEventArgs.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Contexts/AvaloniaSkiaInkCanvasStrokeCollectedEventArgs.cs new file mode 100644 index 0000000..46e764d --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Contexts/AvaloniaSkiaInkCanvasStrokeCollectedEventArgs.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace DotNetCampus.Inking.Contexts; + +public class AvaloniaSkiaInkCanvasStrokeCollectedEventArgs : EventArgs +{ + public AvaloniaSkiaInkCanvasStrokeCollectedEventArgs(int stylusDeviceId, SkiaStroke skiaStroke) + { + StylusDeviceId = stylusDeviceId; + SkiaStroke = skiaStroke; + } + + public int StylusDeviceId { get; } + public SkiaStroke SkiaStroke { get; } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Contexts/DynamicStrokeContext.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Contexts/DynamicStrokeContext.cs new file mode 100644 index 0000000..e243d24 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Contexts/DynamicStrokeContext.cs @@ -0,0 +1,39 @@ +using DotNetCampus.Inking.Interactives; + +namespace DotNetCampus.Inking.Contexts; + +/// +/// 动态笔迹层的上下文,一个手指落下一个对象 +/// +class DynamicStrokeContext +{ + public DynamicStrokeContext(InkingModeInputArgs lastInputArgs, AvaloniaSkiaInkCanvas canvas) + { + LastInputArgs = lastInputArgs; + + var settings = canvas.Context.Settings; + + SkiaSimpleInkRender? simpleInkRender = null; + + if(settings.InkStrokeRenderer is null) + { + simpleInkRender = canvas.SimpleInkRender; + } + + Stroke = new SkiaStroke(InkId.NewId()) + { + Color = settings.InkColor, + InkThickness = settings.InkThickness, + IgnorePressure = settings.IgnorePressure, + InkStrokeRenderer = settings.InkStrokeRenderer, + SimpleInkRender = simpleInkRender, + }; + } + + public InkingModeInputArgs LastInputArgs { get; } + + public int Id => LastInputArgs.Id; + + public SkiaStroke Stroke { get; } + public override string ToString() => $"DynamicStrokeContext_{Id}"; +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Contexts/ErasingCompletedEventArgs.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Contexts/ErasingCompletedEventArgs.cs new file mode 100644 index 0000000..b4a6a04 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Contexts/ErasingCompletedEventArgs.cs @@ -0,0 +1,13 @@ +using DotNetCampus.Inking.Erasing; + +namespace DotNetCampus.Inking.Contexts; + +public class ErasingCompletedEventArgs : EventArgs +{ + public ErasingCompletedEventArgs(IReadOnlyList erasingSkiaStrokeList) + { + ErasingSkiaStrokeList = erasingSkiaStrokeList; + } + + public IReadOnlyList ErasingSkiaStrokeList { get; } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Contexts/SkiaStrokeDrawContext.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Contexts/SkiaStrokeDrawContext.cs new file mode 100644 index 0000000..81175e6 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Contexts/SkiaStrokeDrawContext.cs @@ -0,0 +1,37 @@ +using Avalonia; +using SkiaSharp; + +namespace DotNetCampus.Inking.Contexts; + +readonly record struct SkiaStrokeDrawContext(SKColor Color, SKPath Path, Rect DrawBounds, SKMatrix Transform, bool ShouldDisposePath) : IDisposable +{ + public void Dispose() + { + if (ShouldDisposePath) + { + Path.Dispose(); + } + } +} + +static class SkiaStrokeDrawContextExtension +{ + public static void DrawStroke(this SKCanvas canvas, in SkiaStrokeDrawContext skiaStrokeDrawContext, SKPaint skPaint) + { + skPaint.Color = skiaStrokeDrawContext.Color; + var transform = skiaStrokeDrawContext.Transform; + var useTransform = transform != SKMatrix.Empty && transform != SKMatrix.Identity; + if (useTransform) + { + canvas.Save(); + canvas.Concat(ref transform); + } + + canvas.DrawPath(skiaStrokeDrawContext.Path, skPaint); + + if (useTransform) + { + canvas.Restore(); + } + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Core/AvaloniaSkiaInkCanvas.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Core/AvaloniaSkiaInkCanvas.cs new file mode 100644 index 0000000..5f73007 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Core/AvaloniaSkiaInkCanvas.cs @@ -0,0 +1,618 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Rendering.Composition; +using Avalonia.Rendering.Composition.Transport; +using Avalonia.Rendering.SceneGraph; +using Avalonia.Skia; + +using DotNetCampus.Inking.Caching; +using DotNetCampus.Inking.Contexts; +using DotNetCampus.Inking.Erasing; +using DotNetCampus.Logging; +using DotNetCampus.Numerics.Geometry; + +using SkiaSharp; + +using System.Diagnostics; +using DotNetCampus.Inking.Interactives; +using DotNetCampus.Inking.Utils; +using InkTransformContext = (DotNetCampus.Numerics.Geometry.BoundingBox2D VisibleBounds, DotNetCampus.Numerics.Geometry.SimilarityTransformation2D TransformToRoot); + +namespace DotNetCampus.Inking; + +/// +/// 为 Avalonia 实现的基于 Skia 的 InkCanvas 笔迹画布,可提供动态和静态笔迹层 +/// +/// 既可以单独用作笔迹绘制的接收输入层执行绘制,也可以作为静态笔迹层的承载 +public class AvaloniaSkiaInkCanvas : Control +{ + private readonly InkBitmapCache _cache; + private InkTransformContext? _inkTransformContext; + + public AvaloniaSkiaInkCanvas() + { + _cache = new InkBitmapCache(Context.Settings); + + // 以下是调试代码,用于从文件中读取点列表,绘制到画布上 + // 测试文件要求: 一行一个点,使用逗号分隔,格式为 x,y + //#if DEBUG + // var inkPointList = Path.Join(AppContext.BaseDirectory, "Assets", "Tests", "InkPointList.txt"); + // if (File.Exists(inkPointList)) + // { + // List<(double x, double y)> pointList = []; + // var lines = File.ReadAllLines(inkPointList); + // foreach (var line in lines) + // { + // var point = line.Split(','); + // var x = double.Parse(point[0]); + // var y = double.Parse(point[1]); + // pointList.Add((x, y)); + // } + + // var skiaStroke = new SkiaStroke(InkId.NewId()) + // { + // Color = SKColors.Red, + // InkThickness = 20, + // InkCanvas = this, + // }; + // skiaStroke.AddPoints(pointList.Select(t => new StylusPoint(t.x, t.y))); + // skiaStroke.SetAsStatic(); + // AddStaticStroke(skiaStroke); + // } + //#endif + } + + /// + /// 获取笔迹渲染相关的设置和状态上下文。 + /// + internal AvaloniaSkiaInkCanvasContext Context { get; } = new(); + + /// + /// 获取可以由业务指定的笔迹设置。 + /// + public AvaloniaSkiaInkCanvasSettings Settings => Context.Settings; + + /// + /// 共享的简单笔迹渲染器 + /// + internal SkiaSimpleInkRender SimpleInkRender => _skiaSimpleInkRender ??= new SkiaSimpleInkRender(); + private SkiaSimpleInkRender? _skiaSimpleInkRender; + + internal void AddChild(Control childControl) + { + LogicalChildren.Add(childControl); + VisualChildren.Add(childControl); + } + + internal void RemoveChild(Control childControl) + { + LogicalChildren.Remove(childControl); + VisualChildren.Remove(childControl); + } + + /// + /// 橡皮擦模式 + /// + public AvaloniaSkiaInkCanvasEraserMode EraserMode => _eraserMode ??= new AvaloniaSkiaInkCanvasEraserMode(this); + + private AvaloniaSkiaInkCanvasEraserMode? _eraserMode; + + public void WritingStart() + { + if (_contextDictionary.Count > 0) + { + Log.Warn($"[AvaSkiaInkCanvas][WritingStart] 开始写的时候发现上次书写存在点没有结束 {string.Join(';', _contextDictionary.Keys)}"); + // 兼容性处理,如果上次书写没有结束,那就清空好了 + _contextDictionary.Clear(); + } + + if (Context.ShouldUseBitmapCache) + { + // 开始书写时,禁止使用位图缓存 + // 防止业务端忘记关闭位图缓存,导致动态笔迹无法正确显示 + // 由于 UseBitmapCache 里面包含一次 lock 锁,为了性能考虑,这里先判断状态再调用 + UseBitmapCache(false); + } + } + + public void WritingDown(in InkingModeInputArgs args) + { + EnsureInputConflicts(); + + var dynamicStrokeContext = new DynamicStrokeContext(args, this); + _contextDictionary[args.Id] = dynamicStrokeContext; + dynamicStrokeContext.Stroke.AddPoint(args.StylusPoint); + + InvalidateVisual(); + } + + public void WritingMove(in InkingModeInputArgs args) + { + EnsureInputConflicts(); + + if (_contextDictionary.TryGetValue(args.Id, out var context)) + { + context.Stroke.AddPoint(args.StylusPoint); + InvalidateVisual(); + } + } + + public void WritingUp(in InkingModeInputArgs args) + { + EnsureInputConflicts(); + + if (_contextDictionary.Remove(args.Id, out var context)) + { + context.Stroke.AddPoint(args.StylusPoint); + //_staticStrokeDictionary[context.Stroke.Id] = context.Stroke; + context.Stroke.SetAsStatic(); + _staticStrokeList.Add(context.Stroke); + + StrokeCollected?.Invoke(this, new AvaloniaSkiaInkCanvasStrokeCollectedEventArgs(args.Id, context.Stroke)); + } + InvalidateVisual(); + } + + public event EventHandler? StrokeCollected; + + public void WritingCompleted() + { + Debug.Assert(_contextDictionary.Count == 0, "书写完成时,不应该有未抬起的点"); + _contextDictionary.Clear(); + } + + /// + /// 现在正在写的过程中的字典 + /// + private readonly Dictionary _contextDictionary = []; + +#if DEBUG + private int _count; + private readonly List _list = []; +#endif + + /// + /// 静态笔迹列表 + /// + public IReadOnlyList StaticStrokeList => _staticStrokeList; + + /// + /// 用作静态笔迹层的笔迹列表 + /// + private readonly List _staticStrokeList = []; + + public void AddStaticStroke(SkiaStroke skiaStroke) + { + skiaStroke.EnsureIsStaticStroke(); + if (skiaStroke.InkCanvas != null) + { + // 禁止一个笔迹被添加到多个画布中 + throw new InvalidOperationException("Stroke must not be added to multiple InkCanvas instances."); + } + + _staticStrokeList.Add(skiaStroke); + skiaStroke.InkCanvas = this; + InvalidateVisual(); + } + + public void RemoveStaticStroke(SkiaStroke skiaStroke) + { + skiaStroke.EnsureIsStaticStroke(); + + if (!ReferenceEquals(skiaStroke.InkCanvas, this)) + { + throw new InvalidOperationException("Stroke must be removed from this InkCanvas before removing."); + } + + _staticStrokeList.Remove(skiaStroke); + skiaStroke.InkCanvas = null; + + // 删除笔迹时,关闭位图缓存。这是因为可能存在以下情况: + // 1. 第一次绘制时,笔迹 A 和 B 都在,此时有前置的渲染正在进入等待 + // 2. 用户删除了笔迹 A,设置缓存失效 + // 3. 此时渲染线程执行第一次绘制,获取的信息是笔迹 A 和 B 都在,生成了缓存 + // 4. 第二次绘制时,使用了缓存,笔迹 A 仍然显示 + // 此逻辑无法规避,只能直接在删除笔迹时关闭位图缓存 + UseBitmapCache(false); + + InvalidateVisual(); + } + + internal void AddStaticStrokeWithRenderSynchronizer(SkiaStrokeRenderSynchronizer renderSynchronizer) + { + Context.UseBitmapCache(false); + _renderSynchronizerList.Add(renderSynchronizer); + _staticStrokeList.AddRange(renderSynchronizer.StrokeList); + foreach (var skiaStroke in renderSynchronizer.StrokeList) + { + skiaStroke.InkCanvas = this; + } + InvalidateVisual(); + +#if DEBUG + foreach (var skiaStroke in _staticStrokeList) + { + Debug.Assert(skiaStroke != null); + } +#endif + + //#if DEBUG + // foreach (var skiaStroke in renderSynchronizer.StrokeList) + // { + // DumpStrokeData(skiaStroke); + // } + //#endif + } + + /// + /// 调试用,输出笔迹数据到文件,文件放在桌面 + /// + /// + private void DumpStrokeData(SkiaStroke skiaStroke) + { + var folder = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), nameof(AvaloniaSkiaInkCanvas)); + Directory.CreateDirectory(folder); + var fileName = Path.Join(folder, $"{DateTime.Now:yyMMdd_HH-mm-ss} {skiaStroke.Id.Value}.txt"); + using var streamWriter = new StreamWriter(fileName, append: true); + streamWriter.WriteLine($"Id: {skiaStroke.Id}"); + streamWriter.WriteLine($"Color: {skiaStroke.Color}"); + streamWriter.WriteLine($"InkThickness: {skiaStroke.InkThickness}"); + streamWriter.WriteLine($"Path: {skiaStroke.Path.ToSvgPathData()}"); + streamWriter.WriteLine($"PointCount: {skiaStroke.PointList.Count}"); + streamWriter.WriteLine("PointList: "); + foreach (var point in skiaStroke.PointList) + { + streamWriter.WriteLine($"{point.X},{point.Y}"); + } + } + + /// + /// 渲染用的同步列表 + /// + private readonly List _renderSynchronizerList = []; + + internal void ResetStaticStrokeListByEraserResult(IEnumerable skiaStrokeList) + { + _staticStrokeList.Clear(); + _staticStrokeList.AddRange(skiaStrokeList); + foreach (var skiaStroke in _staticStrokeList) + { +#if DEBUG + skiaStroke.EnsureIsStaticStroke(); +#endif + skiaStroke.InkCanvas = this; + } + InvalidateVisual(); + } + + public override void Render(DrawingContext context) + { +#if DEBUG + foreach (var skiaStroke in _staticStrokeList) + { + Debug.Assert(skiaStroke != null); + } +#endif + + if (_eraserMode?.IsErasing is true) + { + _eraserMode.Render(context); + return; + } + + foreach (var skiaStroke in _staticStrokeList) + { + if (skiaStroke.InkCanvas is null) + { + skiaStroke.InkCanvas = this; + } + } + +#if DEBUG + _count++; + var n = Math.Sin(Math.Pow(Math.E * _count, Math.PI)); + var x = Math.Abs(n) * Bounds.Width; + _count++; + n = Math.Sin(Math.Pow(Math.E * _count, Math.PI)); + var y = Math.Abs(n) * Bounds.Height; + + _list.Add(new Rect(x, y, 10, 10)); +#endif + + UpdateCacheCore(); + var inkCanvasCustomDrawOperation = new InkCanvasCustomDrawOperation(this, _cache); + context.Custom(inkCanvasCustomDrawOperation); + + if (ElementComposition.GetElementVisual(this) is { } selfVisual) + { + Compositor compositor = selfVisual.Compositor; + CompositionBatch batch = compositor.RequestCompositionBatchCommitAsync(); + batch.Rendered.ContinueWith(_ => + { +#if DEBUG // 实际测试不会进入此分支,也就是 Avalonia 的 Render 已经跑完了,但就不知道为什么还没有真的 commit 渲染画面到 DWM 那边,导致 Avalonia 还是落后一个帧。于是 WPF 这边就愉快先消失了,再等一个帧到 Avalonia 显示笔迹出来,表现就是闪烁一下 + if (!inkCanvasCustomDrawOperation.IsFinishRender) + { + // 不再抛出,最小化时,会进入此分支,此时还是预期的 + //throw new Exception($"笔迹开始通知退出时,还没有完成一次渲染!!! 仅调试下抛出"); + } +#endif + + var list = inkCanvasCustomDrawOperation.CurrentRenderSynchronizerList; + foreach (var skiaStrokeRenderSynchronizer in list) + { + skiaStrokeRenderSynchronizer.OnRender(); + } + }); + } + } + + /// + /// 更新笔迹位图缓存所需的一些上下文信息。 + /// + /// 用户可见区域的边界。根坐标系。 + /// 笔迹变换到根(通常是画板,要求画板相对于顶级窗口没有额外变换)的变换矩阵。 + public void UpdateInkTransform(BoundingBox2D visibleBounds, SimilarityTransformation2D transformToRoot) + { + _inkTransformContext = (visibleBounds, transformToRoot); + } + + /// + /// 更新位图缓存的状态。如果没有开启位图缓存,则会关闭位图缓存。 + /// + private void UpdateCacheCore() + { + var scale = GetRenderScaling(); + if (_cache.UseCacheOnNextRender is false && Context.ShouldUseBitmapCache) + { + if (_inkTransformContext is { } inkContext) + { + _cache.UpdateCacheContext(scale, inkContext.VisibleBounds, inkContext.TransformToRoot); + } + else + { + Log.Warn("[Ink][AvaSkiaInkCanvas][UpdateCacheCore] 未设置 InkTransformContext,无法更新缓存。请由笔迹元素调用 UpdateInkTransform 方法更新之。"); + } + _cache.UseCacheOnNextRender = true; + } + else if (_cache.UseCacheOnNextRender && !Context.ShouldUseBitmapCache) + { + _cache.UseCacheOnNextRender = false; + } + } + + class InkCanvasCustomDrawOperation : ICustomDrawOperation + { + private readonly InkBitmapCache _cache; + + public InkCanvasCustomDrawOperation(AvaloniaSkiaInkCanvas inkCanvas, InkBitmapCache cache) + { + _cache = cache; + var contextDictionary = inkCanvas._contextDictionary; + _list = []; + _pathList = []; + + foreach (var skiaStroke in inkCanvas._staticStrokeList) + { + var skiaStrokeDrawContext = skiaStroke.CreateDrawContext(); + _pathList.Add(skiaStrokeDrawContext); + } + + foreach (var strokeContext in contextDictionary.Values) + { + var stroke = strokeContext.Stroke; + + var skiaStrokeDrawContext = stroke.CreateDrawContext(); + _pathList.Add(skiaStrokeDrawContext); + } + + foreach (var skiaStrokeDrawContext in _pathList) + { + _list.Add(skiaStrokeDrawContext.DrawBounds); + } + + _currentRenderSynchronizerList = []; + foreach (var renderSynchronizer in inkCanvas._renderSynchronizerList) + { + bool canAdd = renderSynchronizer.StrokeList.All(skiaStroke => inkCanvas._staticStrokeList.Contains(skiaStroke)); + + if (canAdd) + { + _currentRenderSynchronizerList.Add(renderSynchronizer); + } + } + + foreach (var skiaStrokeRenderSynchronizer in _currentRenderSynchronizerList) + { + inkCanvas._renderSynchronizerList.Remove(skiaStrokeRenderSynchronizer); + } + +#if DEBUG + if (_list.Count == 0) + { + _list = inkCanvas._list; + } +#endif + if (_list.Count == 0) + { + // 如果没有笔迹,那就不需要绘制 + // 设置 Bounds 为 0 将在 Render 中不绘制 + Bounds = new Rect(0, 0, 0, 0); + // 为了防止闪烁,在外层当前渲染次数结束后再通知渲染完成,因此这里不通知渲染完成 + //// 由于在 Render 中不绘制,所以需要先通知渲染完成 + //foreach (var skiaStrokeRenderSynchronizer in _currentRenderSynchronizerList) + //{ + // skiaStrokeRenderSynchronizer.OnRender(); + //} + return; + } + + var list = _list; + + Rect bounds = list[0]; + for (var i = 1; i < list.Count; i++) + { + bounds = bounds.Union(list[i]); + } + Bounds = bounds; + + // 理论上 inkCanvas._renderSynchronizerList 是零个 + Log.Debug($"[Ink][AvaSkiaInkCanvas] InkCanvasCustomDrawOperation 正常执行={inkCanvas._renderSynchronizerList.Count == 0}"); + } + + public IReadOnlyList CurrentRenderSynchronizerList => _currentRenderSynchronizerList; + private readonly List _currentRenderSynchronizerList; + private List _list; + private List _pathList; + + public void Dispose() + { + foreach (var skiaStrokeDrawContext in _pathList) + { + skiaStrokeDrawContext.Dispose(); + } + } + + public bool Equals(ICustomDrawOperation? other) + { + return false; + } + + public bool HitTest(Point p) + { + return false; + } + + public void Render(ImmediateDrawingContext context) + { + var skiaSharpApiLeaseFeature = context.TryGetFeature(); + if (skiaSharpApiLeaseFeature == null) + { + return; + } + + using var skiaSharpApiLease = skiaSharpApiLeaseFeature.Lease(); + var canvas = skiaSharpApiLease.SkCanvas; + DrawCore(canvas); + IsFinishRender = true; + } + + /// + /// 是否已经完成渲染 + /// + public bool IsFinishRender { get; private set; } + + private void DrawCore(SKCanvas canvas) + { + using var skPaint = new SKPaint(); + + skPaint.Color = SKColors.Red; + skPaint.Style = SKPaintStyle.Fill; + skPaint.IsAntialias = true; + skPaint.StrokeWidth = 10; + + if (_cache.UseCacheOnNextRender) + { + // 当缓存可用时,使用此缓存。 + _cache.DrawBitmap([.. _pathList], canvas, skPaint); + } + else if (_pathList.Count > 0) + { + // 当笔迹路径可用时,使用此路径。 + + // 绘制笔迹。 + foreach (var skiaStrokeDrawContext in _pathList) + { + canvas.DrawStroke(in skiaStrokeDrawContext, skPaint); + } + + //// 清除动态层的笔迹渲染,然后清除动态层。 + //foreach (var skiaStrokeRenderSynchronizer in _currentRenderSynchronizerList) + //{ + // Log.Debug($"[Ink][AvaSkiaInkCanvas] 渲染回调 OnRender"); + + // skiaStrokeRenderSynchronizer.OnRender(); + //} + //// 防止重复渲染 + //_currentRenderSynchronizerList.Clear(); + } + else + { + // 当笔迹路径不可用时,使用此调试。 +#if DEBUG + // 仅供调试。 + for (var i = 0; i < _list.Count; i++) + { + var bounds = _list[i]; + var x = (float) bounds.X; + var y = (float) bounds.Y; + + skPaint.Color = new SKColor((uint) (Math.Sin(Math.Pow(Math.E * i, Math.PI)) * int.MaxValue)); + + canvas.DrawRect(x, y, 10, 10, skPaint); + } +#endif + } + } + + public Rect Bounds { get; } + } + + /// + /// 指定是否立即使用位图缓存替代真实的笔迹,或是使用真实的笔迹渲染。 + /// + /// + /// 指定为 ,则立即使用位图缓存替代真实的笔迹;
+ /// 指定为 ,则位图缓存将不会工作,而使用真实的笔迹渲染。 + /// + /// + /// 注意,此方法并不会修改用户设置的位图缓存开关,而是设置在某种程序状态下应该打开还是关闭位图缓存。
+ /// 关于它们之间的区别,请参见 的注释。 + ///
+ public void UseBitmapCache(bool useBitmapCache) + { + Context.UseBitmapCache(useBitmapCache); + UpdateBitmapCache(); + } + + /// + /// 使笔迹的位图缓存失效,并立即重新生成缓存。 + /// + public void UpdateBitmapCache() + { + InvalidateBitmapCache(); + InvalidateVisual(); + } + + /// + /// 使笔迹的位图缓存失效。下次绘制时,如果位图缓存启用,则会重新生成缓存。 + /// + public void InvalidateBitmapCache() + { + var scale = GetRenderScaling(); + if (_inkTransformContext is { } inkContext) + { + _cache.UpdateCacheContext(scale, inkContext.VisibleBounds, inkContext.TransformToRoot); + } + else + { + Log.Warn("[Ink][AvaSkiaInkCanvas][InvalidateBitmapCache] 未设置 InkTransformContext,无法更新缓存。请由笔迹元素调用 UpdateInkTransform 方法更新之。"); + } + } + + private double GetRenderScaling() + { + return TopLevel.GetTopLevel(this)?.RenderScaling ?? 1; + } + + private bool IsWriting => _contextDictionary.Count > 0; + + internal void EnsureInputConflicts() + { + if (IsWriting && EraserMode.IsErasing) + { + throw new InvalidOperationException("Writing and erasing cannot be performed at the same time."); + } + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Core/SkiaStroke.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Core/SkiaStroke.cs new file mode 100644 index 0000000..aa8a51d --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Core/SkiaStroke.cs @@ -0,0 +1,296 @@ +using System.Diagnostics.CodeAnalysis; +using Avalonia; +using Avalonia.Skia; +using DotNetCampus.Inking.Contexts; +using DotNetCampus.Inking.Primitive; +using DotNetCampus.Inking.StrokeRenderers; +using DotNetCampus.Inking.Utils; +using DotNetCampus.Numerics.Geometry; + +using SkiaSharp; + +namespace DotNetCampus.Inking; + +public class SkiaStroke : IDisposable +{ + public SkiaStroke(InkId id) : this(id, new SKPath(), ownSkiaPath: true) + { + } + + private SkiaStroke(InkId id, SKPath path, bool ownSkiaPath) + { + _ownSkiaPath = ownSkiaPath; + Id = id; + Path = path; + } + + /// + /// 笔迹渲染器 + /// + public ISkiaInkStrokeRenderer? InkStrokeRenderer { get; init; } + + public AvaloniaSkiaInkCanvas? InkCanvas { get; set; } + + public InkId Id { get; } + + public SKPath Path { get; private set; } + + public SKMatrix Transform { get; private set; } = SKMatrix.Identity; + + /// + /// 是否拥有 的所有权,即需要在释放的使用同步将其释放 + /// + private readonly bool _ownSkiaPath; + + /// + /// 笔迹颜色 + /// + public SKColor Color { get; init; } = AvaloniaSkiaInkCanvasSettings.DefaultInkColor; + + /// + /// 笔迹粗细 + /// + public float InkThickness { get; init; } = AvaloniaSkiaInkCanvasSettings.DefaultInkThickness; + + internal IReadOnlyList PointList => _pointList; + + /// + /// 是否忽略压感 + /// + public bool IgnorePressure { get; init; } + + private readonly List _pointList = []; + + public void AddPoint(InkStylusPoint point) => AddPoints([point]); + + public void AddPoints(in IEnumerable points) + { + if (_isStaticStroke) + { + throw new InvalidOperationException($"禁止修改静态笔迹的点"); + } + + InkStylusPoint? lastPoint = _pointList.Count > 0 ? PointList[^1] : default; + foreach (InkStylusPoint currentPoint in points) + { + InkStylusPoint point = currentPoint; + + if (IgnorePressure) + { + point = point with + { + Pressure = InkStylusPoint.DefaultPressure, + }; + } + + if (lastPoint == point) + { + // 如果两个点相同,则丢点 + continue; + } + + lastPoint = point; + + _pointList.Add(point); + } + + var pointList = _pointList; + if (InkCanvas?.Settings.ShouldReCreatePoint is true && pointList.Count > 10) + { + pointList = ApplyMeanFilter(pointList); + } + + RenderInk(pointList); + } + + [AllowNull] + internal SkiaSimpleInkRender SimpleInkRender + { + get + { + return _skiaSimpleInkRender ??= new SkiaSimpleInkRender(); + } + init + { + _skiaSimpleInkRender = value; + } + } + + private SkiaSimpleInkRender? _skiaSimpleInkRender; + + private void RenderInk(List pointList) + { + if (InkStrokeRenderer is not null) + { + // 如果有传入渲染器,则使用传入的渲染器 + Path.Dispose(); + Path = InkStrokeRenderer.RenderInkToPath(pointList, InkThickness); + } + else + { + if (pointList.Count >= 2) + { + var outlinePointList = SimpleInkRender.GetOutlineSKPointList(pointList, InkThickness); + + Path.Reset(); + Path.AddPoly(outlinePointList); + } + else if (pointList.Count == 1) + { + Path.Reset(); + var stylusPoint = pointList[0]; + float x = (float) stylusPoint.X; + float y = (float) stylusPoint.Y; + // 如果是一个点,那就画一个圆。圆的半径就是 笔迹粗细 * 压力 / 2 + // 为什么要除以 2,因为传入的是半径 + var radius = InkThickness * stylusPoint.Pressure / 2; + Path.AddCircle(x, y, radius); + } + else + { + // 一个点都没有,那就什么都不画 + } + } + } + + public void Dispose() + { + if (_ownSkiaPath) + { + Path.Dispose(); + } + } + + public static List ApplyMeanFilter(List pointList, int step = 10) + { + var xList = ApplyMeanFilter(pointList.Select(t => t.Point.X).ToList(), step); + var yList = ApplyMeanFilter(pointList.Select(t => t.Point.Y).ToList(), step); + + var newPointList = new List(); + for (int i = 0; i < xList.Count && i < yList.Count; i++) + { + newPointList.Add(new InkStylusPoint(xList[i], yList[i])); + } + + return newPointList; + } + + /// + /// 滤波算法,细节请看 [WPF 记一个特别简单的点集滤波平滑方法 - lindexi - 博客园](https://www.cnblogs.com/lindexi/p/18387840 ) + /// + /// + /// + /// + public static List ApplyMeanFilter(List list, int step) + { + // 前面一半加不了 + var newList = new List(list.Take(step / 2)); + for (int i = step / 2; i < list.Count - step + step / 2; i++) + { + // 当前点,取前后各一半,即 step / 2 个点,求平均值作为当前点的值 + newList.Add(list.Skip(i - step / 2).Take(step).Sum() / step); + } + // 后面一半加不了 + newList.AddRange(list.Skip(list.Count - (step - step / 2))); + return newList; + } + + internal SkiaStrokeDrawContext CreateDrawContext() + { + SKPath skPath; + bool shouldDisposePath; + if (_isStaticStroke) + { + // 静态笔迹,不需要复制,因为不会再更改,不存在线程安全问题 + skPath = Path; + // 静态笔迹不需要释放,释放了会导致绘制闪退 + shouldDisposePath = false; + } + else + { + // 动态笔迹,需要复制,因为可能会在多个线程中绘制使用和释放 + // 如在 UI 线程加点,修改 Path 内容。与此同时在渲染线程绘制,导致多线程同时访问 + // 为了避免这种情况,复制 Path 解决线程安全问题 + skPath = Path.Clone(); + shouldDisposePath = true; + } + + return new SkiaStrokeDrawContext(Color, skPath, GetDrawBounds(), Transform, shouldDisposePath); + } + + internal void SetAsStatic() + { + _drawBounds = GetDrawBounds(); + _isStaticStroke = true; + // 不再需要渲染了,释放渲染器 + _skiaSimpleInkRender = null; + } + + public static SkiaStroke CreateStaticStroke(InkId id, SKPath path, StylusPointListSpan pointList, SKColor color, + float inkThickness, bool ownSkiaPath, ISkiaInkStrokeRenderer? inkStrokeRenderer) + { + var skiaStroke = new SkiaStroke(id, path, ownSkiaPath) + { + Color = color, + InkThickness = inkThickness, + InkStrokeRenderer = inkStrokeRenderer, + }; + + skiaStroke._pointList.EnsureCapacity(pointList.Length); + skiaStroke._pointList.AddRange(pointList.GetEnumerable()); + skiaStroke.SetAsStatic(); + + return skiaStroke; + } + + private bool _isStaticStroke; + private Rect _drawBounds; + + public Rect GetDrawBounds() + { + if (_isStaticStroke) + { + return _drawBounds; + } + + return SkiaSharpExtensions.ToAvaloniaRect(Path.Bounds).ExpandLength(InkThickness); + } + + public void SetTransform(SKMatrix matrix) + { + Transform = matrix; + InkCanvas?.InvalidateVisual(); + } + + public void ApplyTransform(SimilarityTransformation2D transform) + { + for (var i = 0; i < _pointList.Count; i++) + { + var point = _pointList[i]; + _pointList[i] = new InkStylusPoint(transform.Transform(point.Point), point.Pressure); + } + + Path = new SKPath(); + + if (_pointList.Count > 2) + { + var outlinePointList = SimpleInkRender.GetOutlineSKPointList(_pointList, InkThickness); + + Path.Reset(); + Path.AddPoly(outlinePointList); + } + + Transform = SKMatrix.Identity; + _drawBounds = SkiaSharpExtensions.ToAvaloniaRect(Path.Bounds).ExpandLength(InkThickness); + + InkCanvas?.InvalidateVisual(); + } + + public void EnsureIsStaticStroke() + { + if (!_isStaticStroke) + { + throw new InvalidOperationException(); + } + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/DotNetCampus.AvaloniaInkCanvas.csproj b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/DotNetCampus.AvaloniaInkCanvas.csproj new file mode 100644 index 0000000..0617961 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/DotNetCampus.AvaloniaInkCanvas.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + DotNetCampus.Inking + true + + + + + + + + + + + + diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/DotNetCampus.AvaloniaInkCanvas.csproj.DotSettings b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/DotNetCampus.AvaloniaInkCanvas.csproj.DotSettings new file mode 100644 index 0000000..231aea2 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/DotNetCampus.AvaloniaInkCanvas.csproj.DotSettings @@ -0,0 +1,4 @@ + + True + True + True \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/AvaloniaSkiaInkCanvasEraserMode.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/AvaloniaSkiaInkCanvasEraserMode.cs new file mode 100644 index 0000000..eb0ae90 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/AvaloniaSkiaInkCanvasEraserMode.cs @@ -0,0 +1,280 @@ +using System.Diagnostics; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Rendering.SceneGraph; +using Avalonia.Skia; +using DotNetCampus.Inking.Contexts; +using DotNetCampus.Inking.Interactives; +using DotNetCampus.Inking.Utils; +using SkiaSharp; +using Point = Avalonia.Point; +using Rect = Avalonia.Rect; +using Size = Avalonia.Size; + +namespace DotNetCampus.Inking.Erasing; + +public class AvaloniaSkiaInkCanvasEraserMode +{ + public AvaloniaSkiaInkCanvasEraserMode(AvaloniaSkiaInkCanvas inkCanvas) + { + InkCanvas = inkCanvas; + } + + private void InkCanvas_PointerWheelChanged(object? sender, PointerWheelEventArgs e) + { + _debugEraserSizeScale += e.Delta.Y; + } + + private double _debugEraserSizeScale = 0; + + public AvaloniaSkiaInkCanvas InkCanvas { get; } + private AvaloniaSkiaInkCanvasSettings Settings => InkCanvas.Settings; + + public bool IsErasing { get; private set; } + private int MainEraserInputId { set; get; } + + private PointPathEraserManager PointPathEraserManager { get; } = new PointPathEraserManager(); + + private IEraserView EraserView + { + get + { + if (_eraserView is null) + { + var eraserViewCreator = Settings.EraserViewCreator; + _eraserView = eraserViewCreator?.CreateEraserView() + ?? new EraserView(); + } + + return _eraserView; + } + } + + private IEraserView? _eraserView; + + private void StartEraser() + { +#if DEBUG + var topLevel = TopLevel.GetTopLevel(InkCanvas)!; + topLevel.PointerWheelChanged -= InkCanvas_PointerWheelChanged; + topLevel.PointerWheelChanged += InkCanvas_PointerWheelChanged; +#endif + + var staticStrokeList = InkCanvas.StaticStrokeList; + PointPathEraserManager.StartEraserPointPath(staticStrokeList); + + // 如果没有自定义渲染器,则使用简单渲染器 + if (InkCanvas.Settings.InkStrokeRenderer is null) + { + PointPathEraserManager.SimpleInkRender = InkCanvas.SimpleInkRender; + } + + if (EraserView is Control eraserView) + { + InkCanvas.AddChild(eraserView); + } + + _inputProcessStopwatch.Restart(); + _lastEraserSize = Settings.EraserSize; + } + + private readonly Stopwatch _inputProcessStopwatch = new(); + + public void EraserDown(in InkingModeInputArgs args) + { + InkCanvas.EnsureInputConflicts(); + if (!IsErasing) + { + MainEraserInputId = args.Id; + + IsErasing = true; + + StartEraser(); + + EraserView.SetEraserSize(Settings.EraserSize); + EraserView.Move(args.Position.ToAvaloniaPoint()); + InkCanvas.InvalidateVisual(); + } + else + { + // 忽略其他的输入点 + } + } + + private Size _lastEraserSize; + + public void EraserMove(in InkingModeInputArgs args) + { + InkCanvas.EnsureInputConflicts(); + if (IsErasing && args.Id == MainEraserInputId) + { + // 擦除 + var eraserWidth = _lastEraserSize.Width; + var eraserHeight = _lastEraserSize.Height; + + if (Settings.EnableStylusSizeAsEraserSize) + { + var touchWidth = args.StylusPoint.Width ?? eraserWidth; + var touchHeight = args.StylusPoint.Height ?? eraserHeight; + + if (Settings.CanEraserAlwaysFollowsTouchSize || _inputProcessStopwatch.Elapsed < Settings.EraserCanResizeDuringTimeSpan) + { + eraserWidth = touchWidth; + eraserHeight = touchHeight; + } + } + +#if DEBUG + if (_debugEraserSizeScale > 0) + { + _debugEraserSizeScale = Math.Min(100, _debugEraserSizeScale); + + eraserWidth *= (1 + _debugEraserSizeScale / 10); + eraserHeight *= (1 + _debugEraserSizeScale / 10); + } + else if (_debugEraserSizeScale < -10) + { + _debugEraserSizeScale = Math.Max(-100, _debugEraserSizeScale); + + eraserWidth *= (1 + _debugEraserSizeScale / 100); + eraserHeight *= (1 + _debugEraserSizeScale / 100); + } +#endif + + if (Settings.LockMinEraserSize) + { + // 锁定最小橡皮擦 + // 有人嫌弃小咯,那就改大点咯 + eraserWidth = Math.Max(eraserWidth, Settings.MinEraserSize.Width); + eraserHeight = Math.Max(eraserHeight, Settings.MinEraserSize.Height); + } + + // 限制最大橡皮擦,防止那些 SB 设备报告的宽度过大 + eraserWidth = Math.Min(eraserWidth, Settings.MaxEraserSize.Width); + eraserHeight = Math.Min(eraserHeight, Settings.MaxEraserSize.Height); + + var rect = new Rect(args.Position.X - eraserWidth / 2, args.Position.Y - eraserHeight / 2, eraserWidth, eraserHeight); + PointPathEraserManager.Move(rect.ToRect2D()); + + var eraserSize = new Size(eraserWidth, eraserHeight); + _lastEraserSize = eraserSize; + EraserView.SetEraserSize(eraserSize); + EraserView.Move(args.Position.ToAvaloniaPoint()); + InkCanvas.InvalidateVisual(); + } + } + + public void EraserUp(in InkingModeInputArgs args) + { + InkCanvas.EnsureInputConflicts(); + if (IsErasing && args.Id == MainEraserInputId) + { + IsErasing = false; + var pointPathEraserResult = PointPathEraserManager.Finish(); + + var skiaStrokeList = pointPathEraserResult.ErasingSkiaStrokeList + .SelectMany(t => t.IsErased + ? t.NewStrokeList // 被擦掉的,使用新的笔迹列表替代 + : [t.OriginStroke]); // 没有被擦掉的,使用原笔迹 + + InkCanvas.ResetStaticStrokeListByEraserResult(skiaStrokeList); + + ClearEraser(); + + ErasingCompleted?.Invoke(this, new ErasingCompletedEventArgs(pointPathEraserResult.ErasingSkiaStrokeList)); + } + } + + private void ClearEraser() + { + if (EraserView is Control eraserView) + { + InkCanvas.RemoveChild(eraserView); + } + } + + public event EventHandler? ErasingCompleted; + + public void Render(DrawingContext context) + { + context.Custom(new EraserModeCustomDrawOperation(this)); + } + + class EraserModeCustomDrawOperation : ICustomDrawOperation + { + public EraserModeCustomDrawOperation(AvaloniaSkiaInkCanvasEraserMode eraserMode) + { + var pointPathEraserManager = eraserMode.PointPathEraserManager; + IReadOnlyList drawContextList = pointPathEraserManager.GetDrawContextList(); + DrawContextList = drawContextList; + + if (drawContextList.Count == 0) + { + Bounds = new Rect(0, 0, 0, 0); + } + else + { + Rect bounds = drawContextList[0].DrawBounds; + + for (var i = 1; i < drawContextList.Count; i++) + { + bounds = bounds.Union(drawContextList[i].DrawBounds); + } + + Bounds = bounds; + } + } + + private IReadOnlyList DrawContextList { get; } + + public void Dispose() + { + foreach (var skiaStrokeDrawContext in DrawContextList) + { + skiaStrokeDrawContext.Dispose(); + } + } + + public bool Equals(ICustomDrawOperation? other) + { + return false; + } + + public bool HitTest(Point p) + { + return false; + } + + public void Render(ImmediateDrawingContext context) + { + var skiaSharpApiLeaseFeature = context.TryGetFeature(); + if (skiaSharpApiLeaseFeature == null) + { + return; + } + + using var skiaSharpApiLease = skiaSharpApiLeaseFeature.Lease(); + var canvas = skiaSharpApiLease.SkCanvas; + + using var skPaint = new SKPaint(); + skPaint.Color = SKColors.Red; + skPaint.Style = SKPaintStyle.Fill; + + skPaint.IsAntialias = true; + + skPaint.StrokeWidth = 10; + + foreach (var drawContext in DrawContextList) + { + // 绘制 + skPaint.Color = drawContext.Color; + canvas.DrawPath(drawContext.Path, skPaint); + } + } + + public Rect Bounds { get; } + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/ErasedSkiaStroke.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/ErasedSkiaStroke.cs new file mode 100644 index 0000000..f87ee37 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/ErasedSkiaStroke.cs @@ -0,0 +1,35 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace DotNetCampus.Inking.Erasing; + +/// +/// 橡皮擦掉之后的笔迹 +/// +public readonly record struct ErasedSkiaStroke +{ + public ErasedSkiaStroke(SkiaStroke originStroke, IReadOnlyList? newStrokeList, bool isErased) + { + OriginStroke = originStroke; + NewStrokeList = newStrokeList; + IsErased = isErased; + + Debug.Assert(isErased == (newStrokeList != null), "被擦掉的情况下,必定存在列表,即使是空列表"); + } + + /// + /// 原始的笔迹 + /// + public SkiaStroke OriginStroke { get; } + + /// + /// 被擦掉之后的新笔迹列表,可能为空列表。空列表和 null 有区别,空列表表示被擦掉了,但是没有新的笔迹,而 null 表示没被擦掉 + /// + public IReadOnlyList? NewStrokeList { get; } + + /// + /// 是否被擦掉了,即 被擦掉成多条 新的笔迹 + /// + [MemberNotNullWhen(true, nameof(NewStrokeList))] + public bool IsErased { get; } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/PointPathEraserManager.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/PointPathEraserManager.cs new file mode 100644 index 0000000..0f731a7 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/PointPathEraserManager.cs @@ -0,0 +1,398 @@ +using System.Diagnostics; + +using Avalonia.Skia; + +using DotNetCampus.Inking.Contexts; +using DotNetCampus.Inking.Primitive; +using DotNetCampus.Numerics; +using DotNetCampus.Numerics.Geometry; + +using SkiaSharp; + +using Point2D = DotNetCampus.Numerics.Geometry.Point2D; + +namespace DotNetCampus.Inking.Erasing; + +/// +/// 点擦路径擦除 +/// +class PointPathEraserManager +{ + private bool _isErasing; + + public void StartEraserPointPath(IReadOnlyList staticStrokeList) + { + Debug.Assert(_isErasing == false, $"开始橡皮擦时,开始橡皮擦的 {nameof(_isErasing)} 字段状态一定为 false 值"); + Debug.Assert(WorkList.Count == 0, "橡皮擦计算开始的时候,必然此时没有任何笔迹正在被橡皮擦工作中"); + // 兜底代码,确保 WorkList 为空,防止重复加入导致进入诡异的逻辑 + WorkList.Clear(); + + _isErasing = true; + + WorkList.EnsureCapacity(staticStrokeList.Count); + var workList = WorkList; + foreach (var skiaStrokeSynchronizer in staticStrokeList) + { + workList.Add(new InkInfoForEraserPointPath(skiaStrokeSynchronizer)); + } + } + + private List WorkList { get; } = []; + public SkiaSimpleInkRender? SimpleInkRender { get; set; } + + private readonly List _cacheList = []; + + public void Move(Rect2D rect) => Move(new RotatedRect(new Point2D(rect.X, rect.Y), new Size2D(rect.Width, rect.Height), AngularMeasure.Zero)); + + public void Move(RotatedRect rotatedEraserRect) + { + var eraserBoundingBox = rotatedEraserRect.GetBoundingBox(); + var transformation = AffineTransformation2D.Identity.RotateAt(-rotatedEraserRect.Rotate, rotatedEraserRect.Location).Translate(-rotatedEraserRect.Location.ToVector()); + var eraserBoundingBoxRect2D = new Rect2D(eraserBoundingBox.MinX, eraserBoundingBox.MinY, eraserBoundingBox.Width, eraserBoundingBox.Height); + + foreach (InkInfoForEraserPointPath inkInfoForEraserPointPath in WorkList) + { + _cacheList.Clear(); + + // 擦点的核心逻辑 + foreach (SubInkInfoForEraserPointPath pointPath in inkInfoForEraserPointPath.SubInkInfoList) + { + var bounds = pointPath.CacheBounds; + if (!bounds.IntersectsWith(eraserBoundingBoxRect2D)) + { + _cacheList.Add(pointPath); + continue; + } + + var span = pointPath.PointListSpan; + var start = -1; + var length = 0; + + for (int i = 0; i < span.Length; i++) + { + var index = span.Start + i; + var point = inkInfoForEraserPointPath.PointList[index]; + + //var point = inkInfoForEraserPointPath.StrokeSynchronizer.StylusPoints[index].Point; + //_pointCount++; + + if ( + // 1. 短路过滤:快速判断目标点是否在旋转矩形的外接边框内(这可以过滤掉板书中的绝大部分点)。 + eraserBoundingBox.Contains(point) + // 2. 精确判断:判断目标点是否在旋转矩形内。 + // 2.1. 将目标点转换到旋转矩形的坐标系中; + && transformation.Transform(point) is { X: >= 0, Y: >= 0 } transformedPoint + // 2.2. 判断变换后的点是否在矩形内。 + && transformedPoint.X <= rotatedEraserRect.Size.Width + && transformedPoint.Y <= rotatedEraserRect.Size.Height) + { + if (start != -1) + { + // 截断 + _cacheList.Add(pointPath.Sub(start, length)); + } + + start = -1; + length = 0; + } + else + { + if (start == -1) + { + start = index; + length = 1; + } + else + { + length++; + } + } + } + + // 这里的 start 是相对于 pointPath.PointPath 的,而不是相对于当前的 pointPath.PointListSpan 的。因此 start 为 0 不代表就是当前的 pointPath 的起点,而应该是 start == pointPath.PointListSpan.Start 才是代表起点和 pointPath 相同 + if (start == pointPath.PointListSpan.Start && length == pointPath.PointListSpan.Length) + { + // 短路代码,表示这条笔迹一个点都没被擦掉 + _cacheList.Add(pointPath); + } + else + { + if (start != -1) + { + // 截断 + _cacheList.Add(pointPath.Sub(start, length)); + } + + // 截断最后需要将原来释放掉 + pointPath.Dispose(); + } + } + + inkInfoForEraserPointPath.SubInkInfoList.Clear(); + inkInfoForEraserPointPath.SubInkInfoList.AddRange(_cacheList); + +#if DEBUG + foreach (var subInkInfoForEraserPointPath in _cacheList) + { + if (subInkInfoForEraserPointPath.IsDisposed) + { + Debugger.Break(); + } + } +#endif + + _cacheList.Clear(); + } + } + + public PointPathEraserResult Finish() + { + _isErasing = false; + var count = WorkList.Sum(t => t.SubInkInfoList.Count); + var erasingSkiaStrokeList = new List(count); + + foreach (var inkInfoForEraserPointPath in WorkList) + { + var originSkiaStroke = inkInfoForEraserPointPath.OriginSkiaStroke; + + IReadOnlyList? newStrokeList; + if (inkInfoForEraserPointPath.SubInkInfoList.Count == 0) + { + // 笔迹完全被擦掉了 + newStrokeList = Array.Empty(); + } + else if (inkInfoForEraserPointPath.IsErased) + { + var strokeList = new List(inkInfoForEraserPointPath.SubInkInfoList.Count); + newStrokeList = strokeList; + + foreach (var subInkInfoForEraserPointPath in inkInfoForEraserPointPath.SubInkInfoList) + { + var subSpan = subInkInfoForEraserPointPath.PointListSpan; + var pointList = new StylusPointListSpan(originSkiaStroke.PointList, subSpan.Start, subSpan.Length); + + var skPath = subInkInfoForEraserPointPath.CachePath ?? ToPath(subInkInfoForEraserPointPath); + // 已经从 CachePath 取出,不能再有原来的引用,生怕被释放 + subInkInfoForEraserPointPath.CachePath = null; + + var skiaStroke = SkiaStroke.CreateStaticStroke(InkId.NewId(), skPath, pointList, originSkiaStroke.Color, + originSkiaStroke.InkThickness, ownSkiaPath: true, originSkiaStroke.InkStrokeRenderer); + strokeList.Add(skiaStroke); + } + } + else + { + // 没被擦的笔迹依然可以使用原来的笔迹,设计上配置 newStrokeList 为空,减少对象的创建 + // 满屏幕的笔迹,然后只擦掉一个笔迹,如果没有被擦掉的笔迹也创建 List 那将会是一个很大的开销 + newStrokeList = null; + } + + erasingSkiaStrokeList.Add(new ErasedSkiaStroke(originSkiaStroke, newStrokeList, inkInfoForEraserPointPath.IsErased)); + } + + WorkList.Clear(); + var result = new PointPathEraserResult(erasingSkiaStrokeList); + return result; + } + + /// + /// 获取渲染内容 + /// + /// + /// 为什么获取渲染内容需要在准备渲染时才获取,而不是在擦的过程中计算? 原因是机器设备性能太差,擦的过程的进入次数会比渲染次数更多,且在插的过程中计算出来的结果没有被实际使用到,于是不如就在准备渲染的时候计算,如此可以稍微提升一些性能 + public IReadOnlyList GetDrawContextList() + { + var count = WorkList.Sum(t => t.SubInkInfoList.Count); + var result = new List(count); + + foreach (var inkInfoForEraserPointPath in WorkList) + { + var originSkiaStroke = inkInfoForEraserPointPath.OriginSkiaStroke; + if (inkInfoForEraserPointPath.IsErased) + { + // 被擦掉的笔迹,就需要逐个笔迹计算 + foreach (var subInkInfoForEraserPointPath in inkInfoForEraserPointPath.SubInkInfoList) + { + if (subInkInfoForEraserPointPath.IsDisposed) + { + throw new ObjectDisposedException($"当前所使用的 SubInkInfoForEraserPointPath 已经被释放了,橡皮擦状态不正常"); + } + + subInkInfoForEraserPointPath.CachePath ??= ToPath(subInkInfoForEraserPointPath); + // 为什么需要复制一个?原因是接下来的渲染是交给 Avalonia 的渲染线程上,释放时机不固定。原本的在 UI 线程上的 CachePath 的释放时机是这条笔迹被擦到的时候释放,也不能和渲染线程统一,只好进行拷贝一次 + var skPath = subInkInfoForEraserPointPath.CachePath.Clone(); + + result.Add(new SkiaStrokeDrawContext(originSkiaStroke.Color, skPath, skPath.Bounds.ToAvaloniaRect(), SKMatrix.Identity, ShouldDisposePath: true)); + } + } + else + { + // 没被擦的笔迹依然可以使用静态笔迹提升性能 +#if DEBUG + originSkiaStroke.EnsureIsStaticStroke(); +#endif + result.Add(originSkiaStroke.CreateDrawContext()); + } + } + + return result; + } + + private SKPath ToPath(SubInkInfoForEraserPointPath subInkInfoForEraserPointPath) + { + SkiaStroke originSkiaStroke = subInkInfoForEraserPointPath.PointPath.OriginSkiaStroke; + + var subSpan = subInkInfoForEraserPointPath.PointListSpan; + // 对于 WPF 注入的渲染器,只要大于一个点就可以开始渲染了 + if (subSpan.Length > 0) + { + var pointList = new StylusPointListSpan(originSkiaStroke.PointList, subSpan.Start, subSpan.Length); + + if (originSkiaStroke.InkStrokeRenderer is { } inkStrokeRenderer) + { + return inkStrokeRenderer.RenderInkToPath(pointList.ToReadOnlyList(), originSkiaStroke.InkThickness); + } + + // 如果没有自定义的渲染器,就使用默认的渲染器来进行渲染。默认简单渲染器要求大于两个点才能进行渲染 + if (subSpan.Length > 2) + { + SimpleInkRender ??= new SkiaSimpleInkRender(); + var outlinePointList = SimpleInkRender.GetOutlineSKPointList(pointList.ToReadOnlyList(), originSkiaStroke.InkThickness); + + var skPath = new SKPath(); + skPath.AddPoly(outlinePointList); + return skPath; + } + } + + return new SKPath(); + } + + /// + /// 橡皮擦点擦过程中用到的笔迹信息 + /// + /// 用于中间计算使用 + class InkInfoForEraserPointPath + { + public InkInfoForEraserPointPath(SkiaStroke originSkiaStroke) + { + OriginSkiaStroke = originSkiaStroke; + + SubInkInfoList = new List(); + + var subInk = new SubInkInfoForEraserPointPath(new PointListSpan(0, originSkiaStroke.PointList.Count), this); + if (originSkiaStroke.Path is { } skPath) + { + subInk.CacheBounds = skPath.Bounds.ToRect2D(); + } + + SubInkInfoList.Add(subInk); + + PointList = new Point2D[OriginSkiaStroke.PointList.Count]; + for (var i = 0; i < OriginSkiaStroke.PointList.Count; i++) + { + PointList[i] = OriginSkiaStroke.PointList[i].Point; + } + } + + public SkiaStroke OriginSkiaStroke { get; } + + /// + /// 所有实际带的点 + /// + /// 比 结构体小,如此可以提升遍历性能 + public Point2D[] PointList { get; } + + /// + /// 拆分出来的笔迹 + /// + /// 默认会有一条笔迹,就是原始的 + public List SubInkInfoList { get; } + + /// + /// 是否被擦到了 + /// + public bool IsErased + { + get + { + if (SubInkInfoList.Count == 1) + { + var subInk = SubInkInfoList[0]; + if (subInk.PointListSpan.Start == 0 && subInk.PointListSpan.Length == PointList.Length) + { + return false; + } + } + + return true; + } + } + } + + /// + /// 被橡皮擦拆分的子笔迹信息 + /// + class SubInkInfoForEraserPointPath : IDisposable + { + public SubInkInfoForEraserPointPath(PointListSpan pointListSpan, InkInfoForEraserPointPath pointPath) + { + PointListSpan = pointListSpan; + PointPath = pointPath; + } + + public SKPath? CachePath { get; set; } + + public InkInfoForEraserPointPath PointPath { get; } + + public Rect2D CacheBounds + { + get + { + if (_cacheBounds == null) + { + var span = PointPath.PointList.AsSpan(PointListSpan.Start, PointListSpan.Length); + Rect2D bounds = new Rect2D(); + + if (span.Length > 0) + { + bounds = new Rect2D(span[0].X, span[0].Y, 0, 0); + } + + for (int i = 1; i < span.Length; i++) + { + bounds = bounds.Union(span[i]); + } + + _cacheBounds = bounds; + } + + return _cacheBounds.Value; + } + set => _cacheBounds = value; + } + + private Rect2D? _cacheBounds; + + public PointListSpan PointListSpan { get; } + + public SubInkInfoForEraserPointPath Sub(int start, int length) + { + return new SubInkInfoForEraserPointPath(new PointListSpan(start, length), PointPath) + { + _cacheBounds = null + }; + } + + public bool IsDisposed { get; set; } + + public void Dispose() + { + IsDisposed = true; + CachePath?.Dispose(); + CachePath = null; + } + } + + readonly record struct PointListSpan(int Start, int Length); +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/PointPathEraserResult.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/PointPathEraserResult.cs new file mode 100644 index 0000000..bc259b9 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/PointPathEraserResult.cs @@ -0,0 +1,3 @@ +namespace DotNetCampus.Inking.Erasing; + +record PointPathEraserResult(IReadOnlyList ErasingSkiaStrokeList); \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/Views/DelegateEraserViewCreator.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/Views/DelegateEraserViewCreator.cs new file mode 100644 index 0000000..0d05f04 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/Views/DelegateEraserViewCreator.cs @@ -0,0 +1,13 @@ +namespace DotNetCampus.Inking.Erasing; + +/// +/// 使用委托的方式创建 实例的创建器 +/// +/// +public record DelegateEraserViewCreator(Func Creator) : IEraserViewCreator +{ + public IEraserView CreateEraserView() + { + return Creator(); + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/Views/EraserView.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/Views/EraserView.cs new file mode 100644 index 0000000..a38e943 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/Views/EraserView.cs @@ -0,0 +1,73 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace DotNetCampus.Inking.Erasing; + +class EraserView : Control, IEraserView +{ + public EraserView() + { + var rectangleGeometry = new RectangleGeometry(new Rect(0, 0, 48, 72), 3, 3); + Path1 = rectangleGeometry;//Geometry.Parse("M0,5.0093855C0,2.24277828,2.2303666,0,5.00443555,0L24.9955644,0C27.7594379,0,30,2.23861485,30,4.99982044L30,17.9121669C30,20.6734914,30,25.1514578,30,27.9102984L30,40.0016889C30,42.7621799,27.7696334,45,24.9955644,45L5.00443555,45C2.24056212,45,0,42.768443,0,39.9906145L0,5.0093855z"); + //skPaint.Color = new SKColor(0, 0, 0, 0x33); + Path1FillBrush = new SolidColorBrush(new Color(0x33, 0, 0, 0)); + + var bounds = Path1.Bounds; //.Union(Path2.Bounds); + Width = bounds.Width; + Height = bounds.Height; + + HorizontalAlignment = HorizontalAlignment.Left; + VerticalAlignment = VerticalAlignment.Top; + IsHitTestVisible = false; + + var translateTransform = new TranslateTransform(); + _translateTransform = translateTransform; + var scaleTransform = new ScaleTransform(); + _scaleTransform = scaleTransform; + var transformGroup = new TransformGroup(); + transformGroup.Children.Add(_scaleTransform); + transformGroup.Children.Add(_translateTransform); + RenderTransform = transformGroup; + + _currentEraserSize = new Size(Width, Height); + } + + private readonly TranslateTransform _translateTransform; + + private readonly ScaleTransform _scaleTransform; + + private Geometry Path1 { get; } + private IBrush Path1FillBrush { get; } + + //private Geometry Path2 { get; } + //private IBrush Path2FillBrush { get; } + + //private IBrush Path3FillBrush { get; } + + private Size _currentEraserSize; + + public void Move(Point position) + { + _translateTransform.X = position.X - _currentEraserSize.Width / 2; + _translateTransform.Y = position.Y - _currentEraserSize.Height / 2; + } + + public void SetEraserSize(Size size) + { + _scaleTransform.ScaleX = size.Width / Width; + _scaleTransform.ScaleY = size.Height / Height; + + _currentEraserSize = size; + } + + public override void Render(DrawingContext context) + { + context.DrawGeometry(Path1FillBrush, null, Path1); + //skPaint.Color = new SKColor(0xF2, 0xEE, 0xEB, 0xFF); + //skCanvas.DrawRoundRect(1, 1, 28, 43, 4, 4, skPaint); + //context.DrawRectangle(Path3FillBrush, null, new RoundedRect(new Rect(1, 1, 28, 43), 4)); + //context.DrawGeometry(Path2FillBrush, null, Path2); + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/Views/IEraserView.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/Views/IEraserView.cs new file mode 100644 index 0000000..e5c6109 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/Views/IEraserView.cs @@ -0,0 +1,12 @@ +using Avalonia; + +namespace DotNetCampus.Inking.Erasing; + +/// +/// 橡皮擦的视图接口 +/// +public interface IEraserView +{ + void Move(Point position); + void SetEraserSize(Size size); +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/Views/IEraserViewCreator.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/Views/IEraserViewCreator.cs new file mode 100644 index 0000000..46476ae --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Erasing/Views/IEraserViewCreator.cs @@ -0,0 +1,6 @@ +namespace DotNetCampus.Inking.Erasing; + +public interface IEraserViewCreator +{ + IEraserView CreateEraserView(); +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Primitive/StylusPointListSpan.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Primitive/StylusPointListSpan.cs new file mode 100644 index 0000000..0e29f95 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Primitive/StylusPointListSpan.cs @@ -0,0 +1,20 @@ +namespace DotNetCampus.Inking.Primitive; + +public readonly record struct StylusPointListSpan(IReadOnlyList OriginList, int Start, int Length) +{ + public IEnumerable GetEnumerable() + { + return OriginList.Skip(Start).Take(Length); + } + + public IReadOnlyList ToReadOnlyList() + { + var result = new InkStylusPoint[Length]; + for (int i = 0, listIndex = Start; i < Length; i++, listIndex++) + { + result[i] = OriginList[listIndex]; + } + + return result; + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/StrokeRenderers/ISkiaInkStrokeRenderer.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/StrokeRenderers/ISkiaInkStrokeRenderer.cs new file mode 100644 index 0000000..dbff7af --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/StrokeRenderers/ISkiaInkStrokeRenderer.cs @@ -0,0 +1,25 @@ +using DotNetCampus.Inking.Primitive; + +using SkiaSharp; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace DotNetCampus.Inking.StrokeRenderers; + +/// +/// 提供给 Skia 系的笔迹渲染器的接口 +/// +public interface ISkiaInkStrokeRenderer +{ + /// + /// 从给点的点集创建笔迹路径 + /// + /// + /// + /// + SKPath RenderInkToPath(IReadOnlyList pointList, double inkThickness); +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/StrokeRenderers/WpfForSkiaInkStrokeRenderers/SkiaStreamGeometryContext.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/StrokeRenderers/WpfForSkiaInkStrokeRenderers/SkiaStreamGeometryContext.cs new file mode 100644 index 0000000..1460a41 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/StrokeRenderers/WpfForSkiaInkStrokeRenderers/SkiaStreamGeometryContext.cs @@ -0,0 +1,65 @@ +using DotNetCampus.Numerics.Geometry; + +using SkiaSharp; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using WpfInk; + +namespace DotNetCampus.Inking.StrokeRenderers.WpfForSkiaInkStrokeRenderers; + +public class SkiaStreamGeometryContext : IStreamGeometryContext +{ + public SkiaStreamGeometryContext(SKPath path) + { + Path = path; + path.FillType = SKPathFillType.Winding; + } + + public SKPath Path { get; } + + public void BeginFigure(Point2D startPoint, bool isFilled, bool isClosed) + { + Path.MoveTo(startPoint.ToPoint()); + } + + public void PolyBezierTo(IList points, bool isStroked, bool isSmoothJoin) + { + // 传入的 points 必定是 3 的倍数 + + for (var i = 0; i < points.Count; i += 3) + { + var a = points[i]; + var b = points[i + 1]; + var c = points[i + 2]; + + Path.CubicTo(a.ToPoint(), b.ToPoint(), c.ToPoint()); + } + } + + public void PolyLineTo(IList points, bool isStroked, bool isSmoothJoin) + { + foreach (var point in points) + { + Path.LineTo(point.ToPoint()); + } + } + + public void ArcTo(Point2D point, Size2D size, double rotationAngle, bool isLargeArc, bool sweepDirection, bool isStroked, + bool isSmoothJoin) + { + Path.ArcTo((float) size.Width, (float) size.Height, (float) rotationAngle, isLargeArc ? SKPathArcSize.Large : SKPathArcSize.Small, sweepDirection ? SKPathDirection.Clockwise : SKPathDirection.CounterClockwise, (float) point.X, (float) point.Y); + } +} + +file static class Converter +{ + public static SKPoint ToPoint(this Point2D point) + { + return new SKPoint((float) point.X, (float) point.Y); + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/StrokeRenderers/WpfForSkiaInkStrokeRenderers/WpfForSkiaInkStrokeRenderer.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/StrokeRenderers/WpfForSkiaInkStrokeRenderers/WpfForSkiaInkStrokeRenderer.cs new file mode 100644 index 0000000..8bef687 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/StrokeRenderers/WpfForSkiaInkStrokeRenderers/WpfForSkiaInkStrokeRenderer.cs @@ -0,0 +1,30 @@ +using DotNetCampus.Inking.Primitive; +using SkiaSharp; +using WpfInk; + +namespace DotNetCampus.Inking.StrokeRenderers.WpfForSkiaInkStrokeRenderers; + +/// +/// 用 WPF 的笔迹算法提供给 Skia 这边的笔迹支持 +/// +public class WpfForSkiaInkStrokeRenderer : ISkiaInkStrokeRenderer +{ + public SKPath RenderInkToPath(IReadOnlyList pointList, double inkThickness) + { + if (pointList.Count == 0) + { + return new SKPath(); + } + + var path = new SKPath(); + var skiaStreamGeometryContext = new SkiaStreamGeometryContext(path); + + InkStrokeRenderer.Render(skiaStreamGeometryContext, new StrokeRendererInfo() + { + Width = inkThickness / 2, + Height = inkThickness / 2, + StylusPointCollection = pointList + }); + return path; + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Utils/AvaloniaRectExtension.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Utils/AvaloniaRectExtension.cs new file mode 100644 index 0000000..c20e897 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Utils/AvaloniaRectExtension.cs @@ -0,0 +1,17 @@ +using DotNetCampus.Numerics.Geometry; +using SkiaSharp; + +namespace DotNetCampus.Inking.Utils; + +static class AvaloniaRectExtension +{ + //public static Rect2D ToRect2D(this SKRect rect) + //{ + // return new Rect2D(rect.Left, rect.Top, rect.Width, rect.Height); + //} + + public static Rect2D ToRect2D(this Avalonia.Rect rect) + { + return new Rect2D(rect.Left, rect.Top, rect.Width, rect.Height); + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Utils/PointExtension.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Utils/PointExtension.cs new file mode 100644 index 0000000..1e8cf12 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Utils/PointExtension.cs @@ -0,0 +1,16 @@ +using DotNetCampus.Numerics.Geometry; + +namespace DotNetCampus.Inking.Utils; + +static class PointExtension +{ + public static Avalonia.Point ToAvaloniaPoint(this global::DotNetCampus.Numerics.Geometry.Point2D point) + { + return new Avalonia.Point(point.X, point.Y); + } + + public static Point2D ToPoint2D(this Avalonia.Point point) + { + return new Point2D(point.X, point.Y); + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Utils/RectExtension.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Utils/RectExtension.cs new file mode 100644 index 0000000..ea5e5e9 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Utils/RectExtension.cs @@ -0,0 +1,11 @@ +using Avalonia; + +namespace DotNetCampus.Inking.Utils; + +static class RectExtension +{ + public static Rect ExpandLength(this Rect rect, double value) + { + return new Rect(rect.X - value / 2, rect.Y - value / 2, rect.Width + value, rect.Height + value); + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Utils/SkiaRectExtension.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Utils/SkiaRectExtension.cs new file mode 100644 index 0000000..4c905bd --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Utils/SkiaRectExtension.cs @@ -0,0 +1,63 @@ +using DotNetCampus.Numerics; +using DotNetCampus.Numerics.Geometry; + +using SkiaSharp; + +namespace DotNetCampus.Inking.Utils; + +static class SkiaRectExtension +{ + public static SKMatrix ToSkMatrix(this AffineTransformation2D matrix) + { + return new SKMatrix + { + ScaleX = (float) matrix.M11, + SkewX = (float) matrix.M12, + SkewY = (float) matrix.M21, + ScaleY = (float) matrix.M22, + TransX = (float) matrix.OffsetX, + TransY = (float) matrix.OffsetY, + Persp0 = 0, + Persp1 = 0, + Persp2 = 1, + }; + } + + public static SKMatrix ToSkMatrix(this SimilarityTransformation2D transform) + { + return new SKMatrix + { + ScaleX = (float) transform.Scaling, + SkewX = 0, + SkewY = 0, + ScaleY = (float) transform.Scaling, + TransX = (float) transform.Translation.X, + TransY = (float) transform.Translation.Y, + Persp0 = 0, + Persp1 = 0, + Persp2 = 1, + }; + } + + public static AffineTransformation2D ToNumericMatrix(this SKMatrix matrix) + { + return new AffineTransformation2D(matrix.ScaleX, matrix.SkewX, matrix.SkewY, matrix.ScaleY, matrix.TransX, matrix.TransY); + } + + public static SimilarityTransformation2D ToSimilarityTransformation(this SKMatrix matrix) + { + return matrix.ToNumericMatrix().ToSimilarityTransformation2D(); + } + + /// + /// 将 转换为 ,其中剪切变换将被忽略,非等比缩放将使用最大缩放比进行等比缩放。 + /// + /// 仿射变换。 + /// 相似变换。 + public static SimilarityTransformation2D ToSimilarityTransformation2D(this AffineTransformation2D affineTransformation) + { + var decomposition = affineTransformation.Decompose(); + var scale = Math.Max(decomposition.Scaling.ScaleX.Abs(), decomposition.Scaling.ScaleY.Abs()); + return new SimilarityTransformation2D(scale, decomposition.Scaling.ScaleY < 0, decomposition.Rotation, decomposition.Translation); + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Utils/SkiaStrokeRenderSynchronizer.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Utils/SkiaStrokeRenderSynchronizer.cs new file mode 100644 index 0000000..f22ab1d --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.AvaloniaInkCanvas/Utils/SkiaStrokeRenderSynchronizer.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace DotNetCampus.Inking.Utils; + +/// +/// 渲染同步器 +/// +record SkiaStrokeRenderSynchronizer(IReadOnlyList StrokeList, Action OnRender) +{ +} + diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/AssemblyInfo.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/AssemblyInfo.cs new file mode 100644 index 0000000..1b7cbf9 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DotNetCampus.InkCanvas.SkiaInk")] +[assembly: InternalsVisibleTo("DotNetCampus.UnoInkCanvas")] +[assembly: InternalsVisibleTo("DotNetCampus.AvaloniaInkCanvas")] diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/DotNetCampus.InkCanvas.InkCore.csproj b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/DotNetCampus.InkCanvas.InkCore.csproj new file mode 100644 index 0000000..e31787a --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/DotNetCampus.InkCanvas.InkCore.csproj @@ -0,0 +1,16 @@ + + + + net8.0 + enable + enable + DotNetCampus.Inking + true + + + + + + + + diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/DotNetCampus.InkCanvas.InkCore.csproj.DotSettings b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/DotNetCampus.InkCanvas.InkCore.csproj.DotSettings new file mode 100644 index 0000000..6f053dd --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/DotNetCampus.InkCanvas.InkCore.csproj.DotSettings @@ -0,0 +1,3 @@ + + True + True \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Algorithms/DropPointAlgorithm.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Algorithms/DropPointAlgorithm.cs new file mode 100644 index 0000000..eccb0b8 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Algorithms/DropPointAlgorithm.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Text; +using System.Threading.Tasks; +using DotNetCampus.Inking.Primitive; + +namespace DotNetCampus.Inking.Algorithms; + +internal static class DropPointAlgorithm +{ + /// + /// 按照德熙的玄幻算法,决定传入的点是否能丢掉 + /// + /// + /// + /// + /// + private static bool CanDropLastPoint(IReadOnlyList pointList, InkStylusPoint currentStylusPoint, + int dropPointCount) + { + if (pointList.Count < 3) + { + return false; + } + + // 已经丢了10个点了,就不继续丢点了 + if (dropPointCount >= 10) + { + return false; + } + + // 假定要丢掉倒数第一个点,所以上一个点是倒数第二个点 + var lastPoint = pointList[^2].Point; + var currentPoint = currentStylusPoint.Point; + + var lastPointVector = new Vector2((float) lastPoint.X, (float) lastPoint.Y); + var currentPointVector = new Vector2((float) currentPoint.X, (float) currentPoint.Y); + + var lineVector = currentPointVector - lastPointVector; + var lineLength = lineVector.Length(); + + // 如果移动距离比较长,则不丢点 + if (lineLength > 10) + { + return false; + } + + var last2Point = pointList[^3].Point; + var line2Vector = lastPointVector - new Vector2((float) last2Point.X, (float) last2Point.Y); + var line2Length = line2Vector.Length(); + var vector2 = currentPointVector - lastPointVector; + var distance2 = MathF.Abs(line2Vector.X * vector2.Y - line2Vector.Y * vector2.X) / line2Length; + if (distance2 > 2) + { + return false; + } + + return true; + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Context_/InkId.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Context_/InkId.cs new file mode 100644 index 0000000..15c8b04 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Context_/InkId.cs @@ -0,0 +1,11 @@ +namespace DotNetCampus.Inking; + +public readonly partial record struct InkId(int Value) +{ + public static InkId NewId() => new InkId(_nextId++); + + private static int _nextId = 0; + + public override string ToString() + => $"InkId={Value}"; +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Diagnostics/AverageCounter.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Diagnostics/AverageCounter.cs new file mode 100644 index 0000000..c786b4f --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Diagnostics/AverageCounter.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace DotNetCampus.Inking.Diagnostics; + +class AverageCounter +{ + public AverageCounter(string name, int averageMaxCount = 100) : this(averageMaxCount, averageTime => StaticDebugLogger.WriteLine($"{name} 耗时: {averageTime}")) + { + } + + public AverageCounter(int averageMaxCount, AverageCounterRecordHandler handler) + { + _averageMaxCount = averageMaxCount; + _handler = handler; + +#if DEBUG + Enable = true; +#endif + } + + /// + /// 是否可用,用于方便一口气关闭 + /// + public bool Enable { get; set; } + + public void Start() + { + _stopwatch.Restart(); + } + + public void Stop() + { + _stopwatch.Stop(); + _totalTime += _stopwatch.Elapsed.TotalMilliseconds; + _count++; + + if (_count >= _averageMaxCount) + { + _handler(_totalTime / _count); + _totalTime = 0; + _count = 0; + } + } + + private readonly Stopwatch _stopwatch = new Stopwatch(); + private double _totalTime; + private int _count; + private readonly int _averageMaxCount; + private readonly AverageCounterRecordHandler _handler; +} + +delegate void AverageCounterRecordHandler(double averageTime); \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Diagnostics/StaticDebugLogger.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Diagnostics/StaticDebugLogger.cs new file mode 100644 index 0000000..4ee1e2d --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Diagnostics/StaticDebugLogger.cs @@ -0,0 +1,20 @@ +using System.Diagnostics; +using DotNetCampus.Logging; + +namespace DotNetCampus.Inking.Diagnostics; + +static class StaticDebugLogger +{ + [Conditional("False")] + public static void WriteLine(string message) + { + //if (!message.Contains("X11DeviceInputManager")) + //{ + // return; + //} + + Log.Debug($"[InkCore] {message}"); + + //Console.WriteLine(message); + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Diagnostics/StepCounter.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Diagnostics/StepCounter.cs new file mode 100644 index 0000000..2702897 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Diagnostics/StepCounter.cs @@ -0,0 +1,118 @@ +using System.Diagnostics; +using System.Text; + +namespace DotNetCampus.Inking.Diagnostics; + +/// +/// 线性步骤记录器 +/// +class StepCounter +{ + /// + /// 开始 + /// + /// 开始和记录分离,开始不一定是某个步骤。这样业务方修改开始对应的步骤时,可以能够更好的被约束,明确一个开始的时机 + public void Start() + { + Stopwatch.Restart(); + IsStart = true; + } + + public void Restart() + { + IsStart = true; + StepDictionary.Clear(); + Stopwatch.Restart(); + } + + public Stopwatch Stopwatch => _stopwatch ??= new Stopwatch(); + private Stopwatch? _stopwatch; + + /// + /// 记录某个步骤。默认就是一个步骤将会延续到下个步骤,两个步骤之间的耗时就是步骤耗时 + /// 实在不行,那你就加上 “Xx开始” 和 “Xx结束”好了 + /// + /// + public void Record(string step) + { + if (!IsStart) + { + return; + } + + Stopwatch.Stop(); + StepDictionary[step] = Stopwatch.ElapsedTicks; + Stopwatch.Restart(); + } + + public void OutputToConsole() + { + if (!IsStart) + { + return; + } + Console.WriteLine(BuildStepResult()); + } + + /// + /// 进行耗时对比,用于对比两个模块或者两个版本的各个步骤的耗时差 + /// + /// + public void CompareToConsole(StepCounter other) + { + if (!IsStart) + { + return; + } + Console.WriteLine(Compare(other)); + } + + public string Compare(StepCounter other) + { + if (!IsStart) + { + return string.Empty; + } + + var stringBuilder = new StringBuilder(); + foreach (var (step, tick) in StepDictionary) + { + if (other.StepDictionary.TryGetValue(step, out var otherTick)) + { + var sign = tick > otherTick ? "+" : ""; + stringBuilder.AppendLine($"{step} {TickToMillisecond(tick):0.000}ms {TickToMillisecond(otherTick):0.000}ms {sign}{TickToMillisecond(tick - otherTick):0.000}ms"); + } + else + { + stringBuilder.AppendLine($"{step} {tick * 1000d / Stopwatch.Frequency}ms"); + } + } + return stringBuilder.ToString(); + } + + public string BuildStepResult() + { + if (!IsStart) + { + return string.Empty; + } + + var stringBuilder = new StringBuilder(); + foreach (var (step, tick) in StepDictionary) + { + stringBuilder.AppendLine($"{step} {TickToMillisecond(tick)}ms"); + } + return stringBuilder.ToString(); + } + + public Dictionary StepDictionary => _stepDictionary ??= new Dictionary(); + private Dictionary? _stepDictionary; + + /// + /// 是否开始,如果没有开始则啥都不做,用于性能优化,方便一次性注释决定是否测试性能 + /// + public bool IsStart { get; private set; } + + private const double SecondToMillisecond = 1000d; + private static double TickToMillisecond(long tick) => tick * SecondToMillisecond / Stopwatch.Frequency; +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Interactives/IInkingInputProcessor.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Interactives/IInkingInputProcessor.cs new file mode 100644 index 0000000..ad4962b --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Interactives/IInkingInputProcessor.cs @@ -0,0 +1,28 @@ +namespace DotNetCampus.Inking.Interactives; + +/// +/// 输入处理者 +/// +interface IInkingInputProcessor +{ + /// + /// 是否有效,是否接受输入 + /// + bool Enable { get; } + + InkingInputProcessorSettings InputProcessorSettings => InkingInputProcessorSettings.Default; + + void InputStart(); + + void Down(InkingModeInputArgs args); + + void Move(InkingModeInputArgs args); + + void Hover(InkingModeInputArgs args); + + void Up(InkingModeInputArgs args); + + void Leave(); + + void InputComplete(); +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Interactives/IInkingModeInputDispatcherSensitive.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Interactives/IInkingModeInputDispatcherSensitive.cs new file mode 100644 index 0000000..ff1aa2e --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Interactives/IInkingModeInputDispatcherSensitive.cs @@ -0,0 +1,12 @@ +namespace DotNetCampus.Inking.Interactives; + +/// +/// 表示对输入调度器敏感,将被注入 +/// +interface IInkingModeInputDispatcherSensitive +{ + /// + /// 输入调度器 此属性将由框架层注入值 + /// + InkingModeInputDispatcher ModeInputDispatcher { set; get; } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Interactives/InkingInputProcessorSettings.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Interactives/InkingInputProcessorSettings.cs new file mode 100644 index 0000000..1277ace --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Interactives/InkingInputProcessorSettings.cs @@ -0,0 +1,14 @@ +namespace DotNetCampus.Inking.Interactives; + +record InkingInputProcessorSettings +{ + // 不好实现,存在漏洞是首次收到 Move 的情况,此时不仅需要补 Down 还需要补 Start 的情况 + ///// + ///// 对于丢失了 Down 的触摸,是否启用。如启用,则会自动补 Down 事件。默认 false 即丢点 + ///// + //public bool EnableLostDownTouch { init; get; } = false; + + public bool EnableMultiTouch { init; get; } = true; + + public static readonly InkingInputProcessorSettings Default = new InkingInputProcessorSettings(); +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Interactives/InkingModeInputArgs.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Interactives/InkingModeInputArgs.cs new file mode 100644 index 0000000..a79a2c0 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Interactives/InkingModeInputArgs.cs @@ -0,0 +1,19 @@ +using DotNetCampus.Inking.Primitive; +using DotNetCampus.Numerics.Geometry; + +namespace DotNetCampus.Inking.Interactives; + +public readonly record struct InkingModeInputArgs(int Id, InkStylusPoint StylusPoint, ulong Timestamp) +{ + public Point2D Position => StylusPoint.Point; + + /// + /// 是否来自鼠标的输入 + /// + public bool IsMouse { init; get; } + + /// + /// 被合并的其他历史的触摸点。可能为空 + /// + public IReadOnlyList? StylusPointList { init; get; } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Interactives/InkingModeInputDispatcher.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Interactives/InkingModeInputDispatcher.cs new file mode 100644 index 0000000..742f9fd --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Interactives/InkingModeInputDispatcher.cs @@ -0,0 +1,197 @@ +using System.Diagnostics; +using DotNetCampus.Inking.Diagnostics; + +namespace DotNetCampus.Inking.Interactives; + +/// +/// 输入调度器 +/// +class InkingModeInputDispatcher +{ + private HashSet CurrentInputIdHashSet { get; } = new HashSet(); + + /// + /// 首个输入点 + /// + public int MainInputId { private set; get; } + + /// + /// 是否输入开始了 + /// + public bool IsInputStart { private set; get; } + + /// + /// 距离输入过去多久,仅在 为 true 时,才有意义 + /// + public TimeSpan InputDuring => _inputDuringStopwatch.Elapsed; + + private readonly Stopwatch _inputDuringStopwatch = new Stopwatch(); + + public void Down(in InkingModeInputArgs args) + { + CurrentInputIdHashSet.Add(args.Id); + + if (CurrentInputIdHashSet.Count == 1) + { + MainInputId = args.Id; + ProcessInputStart(); + } + + ProcessDown(in args); + } + + private void ProcessInputStart() + { + IsInputStart = true; + _inputDuringStopwatch.Restart(); + + foreach (var inputProcessor in InputProcessors) + { + if (inputProcessor.Enable) + { + inputProcessor.InputStart(); + } + } + } + + private void ProcessDown(in InkingModeInputArgs args) + { + foreach (var inputProcessor in InputProcessors) + { + if (inputProcessor.Enable) + { + inputProcessor.Down(args); + } + } + } + + public void Move(in InkingModeInputArgs args) + { + if (CurrentInputIdHashSet.Contains(args.Id)) + { + foreach (var inputProcessor in InputProcessors) + { + if (inputProcessor.Enable) + { + if (inputProcessor.InputProcessorSettings.EnableMultiTouch || MainInputId == args.Id) + { + inputProcessor.Move(args); + } + } + } + } + else + { + if (args.IsMouse) + { + foreach (var inputProcessor in InputProcessors) + { + if (inputProcessor.Enable) + { + inputProcessor.Hover(args); + } + } + } + else + { + // 非鼠标没有 Hover 效果 + // 如果是在 IsInputStart=false 时,代表触摸离开之后,收到离开之后的消息 + // 对应的问题记录:手势橡皮擦进入工具条时,先触发 Leave 里面,符合预期的进行结束手势橡皮擦。然而后续居然又继续收到 Move 事件,导致判断橡皮擦逻辑工作,再次错误进入了手势橡皮擦模式 + StaticDebugLogger.WriteLine($"[{nameof(InkingModeInputDispatcher)}] Lost Move IsInputStart={IsInputStart} Id={args.Id}"); + } + } + } + + public void Up(InkingModeInputArgs args) + { + if (CurrentInputIdHashSet.Remove(args.Id)) + { + if (args.Id == MainInputId) + { + StaticDebugLogger.WriteLine($"[{nameof(InkingModeInputDispatcher)}] MainIdUp MainId={MainInputId}"); + } + + foreach (var inputProcessor in InputProcessors) + { + if (inputProcessor.Enable) + { + if (inputProcessor.InputProcessorSettings.EnableMultiTouch || MainInputId == args.Id) + { + inputProcessor.Up(args); + } + } + } + + if (CurrentInputIdHashSet.Count == 0) + { + ProcessInputComplete(); + } + } + else + { + // 啥都不能做 + } + } + + private void ProcessInputComplete() + { + foreach (var inputProcessor in InputProcessors) + { + if (inputProcessor.Enable) + { + inputProcessor.InputComplete(); + } + } + IsInputStart = false; + _inputDuringStopwatch.Stop(); + } + + /// + /// 输入被其他拿走了,比如鼠标移动到窗口外抬起 + /// + public void Leave() + { + StaticDebugLogger.WriteLine($"{nameof(InkingModeInputDispatcher)} Leave"); + + foreach (var inputProcessor in InputProcessors) + { + if (inputProcessor.Enable) + { + inputProcessor.Leave(); + } + } + + CurrentInputIdHashSet.Clear(); + IsInputStart = false; + _inputDuringStopwatch.Stop(); + } + + /// + /// 加上输入处理者,有输入时自然执行 + /// + /// + public void AddInputProcessor(IInkingInputProcessor inkingInputProcessor) + { + InputProcessors.Add(inkingInputProcessor); + if (inkingInputProcessor is IInkingModeInputDispatcherSensitive modeInputDispatcherSensitive) + { + modeInputDispatcherSensitive.ModeInputDispatcher = this; + } + } + + private List InputProcessors { get; } = new List(); + + //public bool Enable => true; + + /// + /// 某个触摸 Id 是否存在。不存在则代表被抬起 + /// + /// + /// + public bool ContainsDeviceId(int deviceId) => CurrentInputIdHashSet.Contains(deviceId); + + /// + /// 当前有多少手指按下 + /// + public int CurrentDeviceCount => CurrentInputIdHashSet.Count; +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Primitive/InkStylusPoint.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Primitive/InkStylusPoint.cs new file mode 100644 index 0000000..b1f7e4b --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Primitive/InkStylusPoint.cs @@ -0,0 +1,29 @@ +using DotNetCampus.Numerics.Geometry; + +namespace DotNetCampus.Inking.Primitive; + +public readonly record struct InkStylusPoint +{ + public InkStylusPoint(Point2D point, float pressure = DefaultPressure) + { + Point = point; + Pressure = pressure; + } + + public InkStylusPoint(double x, double y, float pressure = DefaultPressure) : this(new Point2D(x, y), pressure) + { + } + + public double X => Point.X; + public double Y => Point.Y; + + public Point2D Point { init; get; } + public float Pressure { init; get; } + + //public bool IsPressureEnable { init; get; } + + public double? Width { init; get; } + public double? Height { init; get; } + + public const float DefaultPressure = 0.5f; +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Primitive/RotatedRect.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Primitive/RotatedRect.cs new file mode 100644 index 0000000..aceceab --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Primitive/RotatedRect.cs @@ -0,0 +1,293 @@ +using DotNetCampus.Numerics; +using DotNetCampus.Numerics.Geometry; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace DotNetCampus.Inking.Primitive; + +/// +/// 带有旋转的矩形。 +/// +public readonly record struct RotatedRect : ISimilarityTransformable2D +{ + /// + /// 创建一个带有旋转的矩形。 + /// + /// 矩形所在的位置。UI 上表示为未旋转前矩形的左上角。 + /// 矩形的大小。 + /// 矩形的旋转角度。旋转的中心点为 。 + public RotatedRect(Point2D location, Size2D size, AngularMeasure rotate) + { + Location = location; + Size = size; + Rotate = rotate; + } + + /// + /// 矩形所在的位置。UI 上表示为未旋转前矩形的左上角。 + /// + public Point2D Location { get; } + + /// + /// 矩形的大小。 + /// + public Size2D Size { get; } + + /// + /// 矩形的旋转角度。旋转的中心点为 。 + /// + public AngularMeasure Rotate { get; } + + /// + public RotatedRect Transform(SimilarityTransformation2D transformation) + { + var newLocation = transformation.Transform(Location); + var newRotate = Rotate + transformation.Rotation; + if (transformation.IsYScaleNegative) + { + newLocation -= transformation.Scaling * Size.Height * newRotate.UnitVector.NormalVector; + } + + var newSize = new Size2D(Size.Width * transformation.Scaling, Size.Height * transformation.Scaling); + return new RotatedRect(newLocation, newSize, newRotate.Normalized); + } + + /// + /// 判断指定的点是否在矩形内。这里考虑了矩形的旋转。 + /// + /// 要判断的点。 + /// + public bool Contains(Point2D point) + { + var transformation = AffineTransformation2D.Identity.RotateAt(-Rotate, Location); + var transformedPoint = transformation.Transform(point); + return transformedPoint.X >= Location.X && transformedPoint.X <= Location.X + Size.Width && + transformedPoint.Y >= Location.Y && transformedPoint.Y <= Location.Y + Size.Height; + } + + /// + /// 过滤掉不在矩形内的点。这里考虑了矩形的旋转。 + /// + /// + /// + public IEnumerable FilterContained(IEnumerable points) + { + var transformation = AffineTransformation2D.Identity.RotateAt(-Rotate, Location).Translate(-Location.ToVector()); + foreach (var point in points) + { + var transformedPoint = transformation.Transform(point); + if (transformedPoint.X >= 0 && transformedPoint.X <= Size.Width && transformedPoint.Y >= 0 && transformedPoint.Y <= Size.Height) + { + yield return point; + } + } + } + + /// + /// 获取矩形的包围盒。这里考虑了矩形的旋转。 + /// + /// + public BoundingBox2D GetBoundingBox() + { + var transformation = AffineTransformation2D.Identity.RotateAt(Rotate, Location); + var points = new[] + { + Location, + Location + new Vector2D(Size.Width, 0), + Location + new Vector2D(Size.Width, Size.Height), + Location + new Vector2D(0, Size.Height), + }; + + for (var index = 0; index < points.Length; index++) + { + points[index] = transformation.Transform(points[index]); + } + + return BoundingBox2D.Create(points); + } + + public RotatedRect FlipAtBottom() + { + var widthUnitVector = Rotate.UnitVector; + var heightUnitVector = Rotate.UnitVector.NormalVector; + var newLocation = Location + Size.Width * widthUnitVector + 2 * Size.Height * heightUnitVector; + return new RotatedRect(newLocation, Size, (Rotate + AngularMeasure.Pi).Normalized); + } + + /// + /// 是否与另一个带旋转的矩形相交。 + /// + /// 另一个带旋转的矩形。 + /// 是否相交。 + public bool Intersects(RotatedRect other) + { + var widthUnitVector1 = Rotate.UnitVector; + var heightUnitVector1 = Rotate.UnitVector.NormalVector; + var widthUnitVector2 = other.Rotate.UnitVector; + var heightUnitVector2 = other.Rotate.UnitVector.NormalVector; + var axisUnitVectors = new[] + { + widthUnitVector1, + heightUnitVector1, + widthUnitVector2, + heightUnitVector2, + }; + + var vectors1 = new[] + { + Location.ToVector(), + Location.ToVector() + Size.Width * widthUnitVector1, + Location.ToVector() + Size.Width * widthUnitVector1 + Size.Height * heightUnitVector1, + Location.ToVector() + Size.Height * heightUnitVector1, + }; + var vectors2 = new[] + { + other.Location.ToVector(), + other.Location.ToVector() + other.Size.Width * widthUnitVector2, + other.Location.ToVector() + other.Size.Width * widthUnitVector2 + other.Size.Height * heightUnitVector2, + other.Location.ToVector() + other.Size.Height * heightUnitVector2, + }; + + foreach (var axisUnitVector in axisUnitVectors) + { + var min1 = double.MaxValue; + var max1 = double.MinValue; + var min2 = double.MaxValue; + var max2 = double.MinValue; + foreach (var vector in vectors1) + { + var projection = axisUnitVector.Dot(vector); + min1 = Math.Min(min1, projection); + max1 = Math.Max(max1, projection); + } + + foreach (var vector in vectors2) + { + var projection = axisUnitVector.Dot(vector); + min2 = Math.Min(min2, projection); + max2 = Math.Max(max2, projection); + } + + if (max1 < min2 || max2 < min1) + { + return false; + } + } + + return true; + } + + /// + /// 是否与指定的点集相交。 + /// + /// 指定的点集。 + /// 点集是否视为折线。 + /// 是否相交。 + public bool Intersects(IEnumerable points, bool isPolyline) + { + var transformation = AffineTransformation2D.Identity.RotateAt(-Rotate, Location).Translate(-Location.ToVector()); + + if (isPolyline) + { + Point2D? lastPoint = null; + foreach (var point in points) + { + var currentPoint = transformation.Transform(point); + + if (lastPoint is not { } lastPointValue) + { + if (currentPoint.X >= 0 && currentPoint.X <= Size.Width && currentPoint.Y >= 0 && currentPoint.Y <= Size.Height) + { + return true; + } + + lastPoint = point; + continue; + } + + var vector = currentPoint - lastPointValue; + if (vector.LengthSquared.IsAlmostZero()) + { + continue; + } + + if (!vector.X.IsAlmostEqual(0)) + { + var k = vector.Y / vector.X; + var y = currentPoint.Y - currentPoint.X * k; + if (y >= 0 && y <= Size.Height) + { + return true; + } + + y = currentPoint.Y - (currentPoint.X - Size.Width) * k; + if (y >= 0 && y <= Size.Height) + { + return true; + } + } + + if (!vector.Y.IsAlmostEqual(0)) + { + // 斜率的倒数 + var rk = vector.X / vector.Y; + var x = currentPoint.X - currentPoint.Y * rk; + if (x >= 0 && x <= Size.Width) + { + return true; + } + + x = currentPoint.X - (currentPoint.Y - Size.Height) * rk; + if (x >= 0 && x <= Size.Width) + { + return true; + } + } + + lastPoint = point; + } + } + else + { + foreach (var point in points) + { + var transformedPoint = transformation.Transform(point); + if (transformedPoint.X >= 0 && transformedPoint.X <= Size.Width && transformedPoint.Y >= 0 && transformedPoint.Y <= Size.Height) + { + return true; + } + } + } + + return false; + } + + /// + /// 在矩形的四个边上分别向外扩张形成新的矩形。 + /// + /// 宽度上的扩张量。 + /// 高度上的扩张量。 + /// 扩张后的矩形。 + public RotatedRect Inflate(double widthAmount, double heightAmount) + { + var widthUnitVector = Rotate.UnitVector; + var heightUnitVector = Rotate.UnitVector.NormalVector; + var newLocation = Location - widthAmount * widthUnitVector - heightAmount * heightUnitVector; + var newSize = new Size2D(Size.Width + 2 * widthAmount, Size.Height + 2 * heightAmount); + return new RotatedRect(newLocation, newSize, Rotate); + } + + /// + /// 在矩形的四个边上分别向外扩张形成新的矩形。 + /// + /// 扩张量。 + /// 扩张后的矩形。 + public RotatedRect Inflate(double amount) + { + return Inflate(amount, amount); + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Utils/InkingFixedQueue.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Utils/InkingFixedQueue.cs new file mode 100644 index 0000000..249fd9f --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/Inking/Utils/InkingFixedQueue.cs @@ -0,0 +1,148 @@ +using System.Collections; +using System.ComponentModel; + +namespace DotNetCampus.Inking.Utils; + +/// +/// 带最大数量的队列,超过最大数量将会自动将队头元素出队丢弃 +/// +/// +class InkingFixedQueue : ICollection, IEnumerable +{ + private readonly Queue _innerQueue = new Queue(); + + /// + /// 创建带最大数量的队列 + /// + /// + public InkingFixedQueue(int maxCount) + { + MaxCount = maxCount; + } + + /// + /// 队列可以使用的最大元素数量 + /// + public int MaxCount { get; private set; } + + #region Queue相关的成员 + /// + /// Gets the enumerator. + /// + /// + public IEnumerator GetEnumerator() + { + return _innerQueue.GetEnumerator(); + } + + /// + /// 返回一个循环访问集合的枚举器。 + /// + /// + /// 可用于循环访问集合的 对象。 + /// + IEnumerator IEnumerable.GetEnumerator() + { + return _innerQueue.GetEnumerator(); + } + + /// + /// 从特定的 索引处开始,将 的元素复制到一个 中。 + /// + /// 作为从 复制的元素的目标位置的一维 必须具有从零开始的索引。 + /// 中从零开始的索引,将在此处开始复制。 + public void CopyTo(Array array, int index) + { + ((ICollection) _innerQueue).CopyTo(array, index); + } + + /// + /// Copies to. + /// + /// The array. + /// The index. + public void CopyTo(T[] array, int index) + { + _innerQueue.CopyTo(array, index); + } + + /// + /// 获取 中包含的元素数。 + /// + /// + /// 中包含的元素数。 + public int Count { get { return _innerQueue.Count; } } + /// + /// 获取一个可用于同步对 的访问的对象。 + /// + /// 可用于同步对 的访问的对象。 + public object SyncRoot { get { return ((ICollection) _innerQueue).SyncRoot; } } + + /// + /// 获取一个值,该值指示是否同步对 的访问(线程安全)。 + /// + /// 如果对 的访问是同步的(线程安全),则为 true;否则为 false。 + public bool IsSynchronized => false;// 因为包装不是线程安全的,如 Enqueue 等方法,所以整个类是线程不安全的 + #endregion + + /// + /// 将对象添加到队列结尾处 + /// + /// + public void Enqueue(T item) + { + if (_innerQueue.Count > MaxCount) + { + throw new InvalidOperationException("集合中的元素已超过最大限定值。"); + } + if (_innerQueue.Count == MaxCount) + { + _innerQueue.Dequeue(); + } + _innerQueue.Enqueue(item); + } + + /// + /// 移除并返回队列开始处元素 + /// + /// 一个没有被使用的元素,请随意传入,这是设计问题,但为了兼容性,暂时保存 + /// + [Obsolete("请使用不带参数的 Dequeue 方法代替,这个方法传入的参数没有被使用")] + [EditorBrowsable(EditorBrowsableState.Never)] + public T Dequeue(T item) + { + return _innerQueue.Dequeue(); + } + + /// + /// 移除并返回队列开始处元素 + /// + public T Dequeue() => _innerQueue.Dequeue(); + + /// + /// 返回队列开始处的对象但不将这个对象移除 + /// + /// + public T Peek() + { + return _innerQueue.Peek(); + } + + /// + /// 确定某元素是否在队列存在 + /// + /// + /// + public bool Contains(T item) + { + return _innerQueue.Contains(item); + } + + /// + /// 清空队列 + /// + public void Clear() + { + _innerQueue.Clear(); + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/ReferenceCode/Mathematics/SpatialGeometry/Rect2D.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/ReferenceCode/Mathematics/SpatialGeometry/Rect2D.cs new file mode 100644 index 0000000..6d4f27e --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/ReferenceCode/Mathematics/SpatialGeometry/Rect2D.cs @@ -0,0 +1,121 @@ +using System.Drawing; +using DotNetCampus.Numerics.Geometry; +using Rect = DotNetCampus.Numerics.Geometry.Rect2D; + +namespace DotNetCampus.Numerics.Geometry; + +#if HAS_UNO +public +#endif +readonly record struct Rect2D(Point2D Location, Size2D Size) +{ + public Rect2D(double x, double y, double width, double height) : this(new Point2D(x, y), new Size2D(width, height)) + { + } + + public double X => Location.X; + public double Y => Location.Y; + + public double Width => Size.Width; + public double Height => Size.Height; + + public double Left => X; + public double Top => Y; + public double Right => X + Width; + public double Bottom => Y + Height; + + public bool IsEmpty => (Width <= 0) || (Height <= 0); + + public static Rect2D Zero => default; + + public static Rect2D FromLTRB(double left, double top, double right, double bottom) + { + return new Rect2D(left, top, right - left, bottom - top); + } + + public bool Contains(Rect2D rect) + { + return X <= rect.X && Right >= rect.Right && Y <= rect.Y && Bottom >= rect.Bottom; + } + + public bool Contains(Point2D pt) + { + return Contains(pt.X, pt.Y); + } + + public bool Contains(double x, double y) + { + return (x >= Left) && (x < Right) && (y >= Top) && (y < Bottom); + } + + public bool IntersectsWith(Rect2D r) + { + return !((Left >= r.Right) || (Right <= r.Left) || (Top >= r.Bottom) || (Bottom <= r.Top)); + } + + public Rect2D Union(Rect2D r) + { + return Union(this, r); + } + + public Rect Union(Point2D pt) + { + var left = Math.Min(Left, pt.X); + var top = Math.Min(Top, pt.Y); + var right = Math.Max(Right, pt.X); + var bottom = Math.Max(Bottom, pt.Y); + return FromLTRB(left, top, right, bottom); + } + + public static Rect2D Union(Rect2D r1, Rect2D r2) + { + return FromLTRB(Math.Min(r1.Left, r2.Left), Math.Min(r1.Top, r2.Top), Math.Max(r1.Right, r2.Right), Math.Max(r1.Bottom, r2.Bottom)); + } + + public Rect2D Intersect(Rect2D r) + { + return Intersect(this, r); + } + + public static Rect2D Intersect(Rect2D r1, Rect2D r2) + { + double x = Math.Max(r1.X, r2.X); + double y = Math.Max(r1.Y, r2.Y); + double width = Math.Min(r1.Right, r2.Right) - x; + double height = Math.Min(r1.Bottom, r2.Bottom) - y; + + if (width < 0 || height < 0) + { + return Zero; + } + return new Rect2D(x, y, width, height); + } + + public Rect2D Inflate(Size2D sz) + { + return Inflate(sz.Width, sz.Height); + } + + public Rect2D Inflate(double width, double height) + { + return new Rect2D(X - width, Y - height, Width + width, Height + height); + } + + public Rect2D Offset(double dx, double dy) + { + return this with + { + Location = new Point2D(X + dx, Y + dy) + }; + } + + public Rect2D Offset(Point2D dr) + { + return Offset(dr.X, dr.Y); + } + + public Rect2D Round() + { + return new Rect2D(Math.Round(X), Math.Round(Y), Math.Round(Width), Math.Round(Height)); + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/SimpleInkRender.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/SimpleInkRender.cs new file mode 100644 index 0000000..16c1b74 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/SimpleInkRender.cs @@ -0,0 +1,159 @@ +using System.Numerics; +using DotNetCampus.Inking.Primitive; +using Point = DotNetCampus.Numerics.Geometry.Point2D; + +namespace DotNetCampus.Inking +{ + /// + /// 特别简单的笔迹渲染器。 + /// + internal static class SimpleInkRender + { + private static readonly Matrix3x2 RotationPiDiv8 = Matrix3x2.CreateRotation(MathF.PI / 8); + private static readonly Matrix3x2 RotationPiDiv4 = Matrix3x2.CreateRotation(MathF.PI / 4); + private static readonly Matrix3x2 Rotation3PiDiv8 = Matrix3x2.CreateRotation(3 * MathF.PI / 8); + + public static Point[] GetOutlinePointList(IReadOnlyList pointList, double inkSize) + { + if (pointList.Count < 2) + { + throw new ArgumentException("小于两个点的无法应用算法"); + } + + var outlinePointList1 = new List(pointList.Count * 2); + var outlinePointList2 = new List(pointList.Count * 2); + + for (var i = 0; i < pointList.Count; i++) + { + // 笔迹粗细的一半,一边用一半,合起来就是笔迹粗细了 + var halfThickness = (float) inkSize / 2; + + // 压感这里是直接乘法而已 + halfThickness *= pointList[i].Pressure; + // 不能让笔迹粗细太小 + halfThickness = MathF.Max(0.01f, halfThickness); + + if (i == 0 || pointList[i].Point == pointList[i - 1].Point) + { + if (i == pointList.Count - 1 || pointList[i].Point == pointList[i + 1].Point) + { + continue; + } + + var direction = Vector2.Multiply(halfThickness, Vector2.Normalize(new Vector2((float) pointList[i + 1].Point.X - (float) pointList[i].Point.X, (float) pointList[i + 1].Point.Y - (float) pointList[i].Point.Y))); + + var point1 = new Point(pointList[i].Point.X - direction.Y, pointList[i].Point.Y + direction.X); + var point2 = new Point(pointList[i].Point.X + direction.Y, pointList[i].Point.Y - direction.X); + + if (i == 0) + { + var direction0 = -direction; + var direction1 = Vector2.Transform(direction0, RotationPiDiv8); + var direction2 = Vector2.Transform(direction0, RotationPiDiv4); + var direction3 = Vector2.Transform(direction0, Rotation3PiDiv8); + var directionN1 = new Vector2(direction3.Y, -direction3.X); + var directionN2 = new Vector2(direction2.Y, -direction2.X); + var directionN3 = new Vector2(direction1.Y, -direction1.X); + + outlinePointList1.Add(new Point(pointList[i].Point.X + direction0.X, pointList[i].Point.Y + direction0.Y)); + outlinePointList1.Add(new Point(pointList[i].Point.X + directionN1.X, pointList[i].Point.Y + directionN1.Y)); + outlinePointList1.Add(new Point(pointList[i].Point.X + directionN2.X, pointList[i].Point.Y + directionN2.Y)); + outlinePointList1.Add(new Point(pointList[i].Point.X + directionN3.X, pointList[i].Point.Y + directionN3.Y)); + + outlinePointList2.Add(new Point(pointList[i].Point.X + direction0.X, pointList[i].Point.Y + direction0.Y)); + outlinePointList2.Add(new Point(pointList[i].Point.X + direction1.X, pointList[i].Point.Y + direction1.Y)); + outlinePointList2.Add(new Point(pointList[i].Point.X + direction2.X, pointList[i].Point.Y + direction2.Y)); + outlinePointList2.Add(new Point(pointList[i].Point.X + direction3.X, pointList[i].Point.Y + direction3.Y)); + } + + outlinePointList1.Add(point1); + outlinePointList2.Add(point2); + } + else if (i == pointList.Count - 1 || pointList[i].Point == pointList[i + 1].Point) + { + var direction = Vector2.Multiply(halfThickness, Vector2.Normalize(new Vector2((float) pointList[i].Point.X - (float) pointList[i - 1].Point.X, (float) pointList[i].Point.Y - (float) pointList[i - 1].Point.Y))); + + var point1 = new Point(pointList[i].Point.X - direction.Y, pointList[i].Point.Y + direction.X); + var point2 = new Point(pointList[i].Point.X + direction.Y, pointList[i].Point.Y - direction.X); + + outlinePointList1.Add(point1); + outlinePointList2.Add(point2); + + if (i == pointList.Count - 1) + { + var rotationPiDiv8 = Matrix3x2.CreateRotation(MathF.PI / 8); + var rotationPiDiv4 = Matrix3x2.CreateRotation(MathF.PI / 4); + var rotation3PiDiv8 = Matrix3x2.CreateRotation(3 * MathF.PI / 8); + + var direction0 = direction; + var direction1 = Vector2.Transform(direction0, rotationPiDiv8); + var direction2 = Vector2.Transform(direction0, rotationPiDiv4); + var direction3 = Vector2.Transform(direction0, rotation3PiDiv8); + var directionN1 = new Vector2(direction3.Y, -direction3.X); + var directionN2 = new Vector2(direction2.Y, -direction2.X); + var directionN3 = new Vector2(direction1.Y, -direction1.X); + + outlinePointList1.Add(new Point(pointList[i].Point.X + direction3.X, pointList[i].Point.Y + direction3.Y)); + outlinePointList1.Add(new Point(pointList[i].Point.X + direction2.X, pointList[i].Point.Y + direction2.Y)); + outlinePointList1.Add(new Point(pointList[i].Point.X + direction1.X, pointList[i].Point.Y + direction1.Y)); + outlinePointList1.Add(new Point(pointList[i].Point.X + direction0.X, pointList[i].Point.Y + direction0.Y)); + + outlinePointList2.Add(new Point(pointList[i].Point.X + directionN3.X, pointList[i].Point.Y + directionN3.Y)); + outlinePointList2.Add(new Point(pointList[i].Point.X + directionN2.X, pointList[i].Point.Y + directionN2.Y)); + outlinePointList2.Add(new Point(pointList[i].Point.X + directionN1.X, pointList[i].Point.Y + directionN1.Y)); + outlinePointList2.Add(new Point(pointList[i].Point.X + direction0.X, pointList[i].Point.Y + direction0.Y)); + } + } + else + { + var direction1 = Vector2.Multiply(halfThickness, Vector2.Normalize(new Vector2((float) pointList[i].Point.X - (float) pointList[i - 1].Point.X, (float) pointList[i].Point.Y - (float) pointList[i - 1].Point.Y))); + var direction2 = Vector2.Multiply(halfThickness, Vector2.Normalize(new Vector2((float) pointList[i + 1].Point.X - (float) pointList[i].Point.X, (float) pointList[i + 1].Point.Y - (float) pointList[i].Point.Y))); + + var vector11 = new Vector2(-direction1.Y, direction1.X); + var vector12 = new Vector2(direction1.Y, -direction1.X); + var vector21 = new Vector2(-direction2.Y, direction2.X); + var vector22 = new Vector2(direction2.Y, -direction2.X); + + switch (-direction1.X * direction2.Y + direction1.Y * direction2.X) + { + case < 0: + { + var vector1 = Vector2.Normalize(vector11 + vector21) * halfThickness; + var vector2 = Vector2.Normalize(vector12 + vector22) * halfThickness; + + outlinePointList1.Add(new Point(pointList[i].Point.X + vector1.X, pointList[i].Point.Y + vector1.Y)); + outlinePointList2.Add(new Point(pointList[i].Point.X + vector12.X, pointList[i].Point.Y + vector12.Y)); + outlinePointList2.Add(new Point(pointList[i].Point.X + vector2.X, pointList[i].Point.Y + vector2.Y)); + outlinePointList2.Add(new Point(pointList[i].Point.X + vector22.X, pointList[i].Point.Y + vector22.Y)); + break; + } + case > 0: + { + var vector1 = Vector2.Normalize(vector11 + vector21) * halfThickness; + var vector2 = Vector2.Normalize(vector12 + vector22) * halfThickness; + + outlinePointList1.Add(new Point(pointList[i].Point.X + vector11.X, pointList[i].Point.Y + vector11.Y)); + outlinePointList1.Add(new Point(pointList[i].Point.X + vector1.X, pointList[i].Point.Y + vector1.Y)); + outlinePointList1.Add(new Point(pointList[i].Point.X + vector21.X, pointList[i].Point.Y + vector21.Y)); + outlinePointList2.Add(new Point(pointList[i].Point.X + vector2.X, pointList[i].Point.Y + vector2.Y)); + break; + } + default: + outlinePointList1.Add(new Point(pointList[i].Point.X + vector11.X, pointList[i].Point.Y + vector11.Y)); + outlinePointList1.Add(new Point(pointList[i].Point.X + vector21.X, pointList[i].Point.Y + vector21.Y)); + outlinePointList2.Add(new Point(pointList[i].Point.X + vector12.X, pointList[i].Point.Y + vector12.Y)); + outlinePointList2.Add(new Point(pointList[i].Point.X + vector22.X, pointList[i].Point.Y + vector22.Y)); + break; + } + } + } + + var outlinePoints = new Point[outlinePointList1.Count + outlinePointList2.Count + 1]; + outlinePointList2.Reverse(); + outlinePointList1.CopyTo(outlinePoints, 0); + outlinePointList2.CopyTo(outlinePoints, outlinePointList1.Count); + outlinePoints[^1] = outlinePoints[0]; + return outlinePoints; + } + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/API/Converter.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/API/Converter.cs new file mode 100644 index 0000000..551e458 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/API/Converter.cs @@ -0,0 +1,17 @@ +using DotNetCampus.Inking.Primitive; +using DotNetCampus.Numerics.Geometry; +using WpfInk.PresentationCore.System.Windows; +using WpfInk.PresentationCore.System.Windows.Input.Stylus; + +namespace WpfInk; + +static class Converter +{ + public static Point2D ToPoint(this Point point) => new Point2D(point.X, point.Y); + public static Size2D ToSize(this Size size) => new Size2D(size.Width, size.Height); + + public static StylusPoint ToStylusPoint(this InkStylusPoint stylusPoint) + { + return new StylusPoint(stylusPoint.X, stylusPoint.Y, stylusPoint.Pressure); + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/API/IInternalStreamGeometryContext.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/API/IInternalStreamGeometryContext.cs new file mode 100644 index 0000000..8734fc8 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/API/IInternalStreamGeometryContext.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using WpfInk.PresentationCore.System.Windows; + +namespace WpfInk; + +internal interface IInternalStreamGeometryContext +{ + void BeginFigure(Point startPoint, bool isFilled, bool isClosed); + void PolyBezierTo(IList points, bool isStroked, bool isSmoothJoin); + void PolyLineTo(IList points, bool isStroked, bool isSmoothJoin); + void ArcTo(Point point, Size size, double rotationAngle, bool isLargeArc, bool sweepDirection, bool isStroked, bool isSmoothJoin); +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/API/IStreamGeometryContext.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/API/IStreamGeometryContext.cs new file mode 100644 index 0000000..ae9fa0b --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/API/IStreamGeometryContext.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using DotNetCampus.Numerics.Geometry; + +namespace WpfInk; + +public interface IStreamGeometryContext +{ + void BeginFigure(Point2D startPoint, bool isFilled, bool isClosed); + void PolyBezierTo(IList points, bool isStroked, bool isSmoothJoin); + void PolyLineTo(IList points, bool isStroked, bool isSmoothJoin); + void ArcTo(Point2D point, Size2D size, double rotationAngle, bool isLargeArc, bool sweepDirection, bool isStroked, bool isSmoothJoin); +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/API/InkStrokeRenderer.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/API/InkStrokeRenderer.cs new file mode 100644 index 0000000..861455c --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/API/InkStrokeRenderer.cs @@ -0,0 +1,30 @@ +using DotNetCampus.Inking.Primitive; +using MS.Internal.Ink; +using WpfInk.PresentationCore.System.Windows.Ink; +using WpfInk.PresentationCore.System.Windows.Input.Stylus; + +namespace WpfInk; + +public static class InkStrokeRenderer +{ + public static void Render(IStreamGeometryContext streamGeometryContext, in StrokeRendererInfo info) + { + var drawingAttributes = new DrawingAttributes() + { + Width = info.Width, + Height = info.Height, + }; + + var stylusPointCollection = new StylusPointCollection(info.StylusPointCollection.Count); + + foreach (InkStylusPoint inkStylusPoint2D in info.StylusPointCollection) + { + stylusPointCollection.Add(inkStylusPoint2D.ToStylusPoint()); + } + + var stroke = new Stroke(stylusPointCollection, drawingAttributes); + StrokeNodeIterator strokeNodeIterator = StrokeNodeIterator.GetIterator(stroke, drawingAttributes); + var internalStreamGeometryContext = new InternalStreamGeometryContext(streamGeometryContext); + StrokeRenderer.CalcGeometryAndBounds(strokeNodeIterator, drawingAttributes, calculateBounds: false, internalStreamGeometryContext, out _); + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/API/InternalStreamGeometryContext.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/API/InternalStreamGeometryContext.cs new file mode 100644 index 0000000..390051a --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/API/InternalStreamGeometryContext.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Linq; +using DotNetCampus.Numerics.Geometry; +using WpfInk.PresentationCore.System.Windows; + +namespace WpfInk; + +internal class InternalStreamGeometryContext : IInternalStreamGeometryContext +{ + public InternalStreamGeometryContext(IStreamGeometryContext context) + { + _context = context; + } + + private readonly IStreamGeometryContext _context; + private readonly List _cacheList = new List(); + + public void BeginFigure(Point startPoint, bool isFilled, bool isClosed) + { + _context.BeginFigure(startPoint.ToPoint(), isFilled, isClosed); + } + + public void PolyBezierTo(IList points, bool isStroked, bool isSmoothJoin) + { + _cacheList.Clear(); + _cacheList.AddRange(points.Select(t => t.ToPoint())); + _context.PolyBezierTo(_cacheList, isStroked, isSmoothJoin); + } + + public void PolyLineTo(IList points, bool isStroked, bool isSmoothJoin) + { + _cacheList.Clear(); + _cacheList.AddRange(points.Select(t => t.ToPoint())); + _context.PolyLineTo(_cacheList, isStroked, isSmoothJoin); + } + + public void ArcTo(Point point, Size size, double rotationAngle, bool isLargeArc, bool sweepDirection, bool isStroked, + bool isSmoothJoin) + { + _context.ArcTo(point.ToPoint(), size.ToSize(), rotationAngle, isLargeArc, sweepDirection, isStroked, isSmoothJoin); + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/API/StrokeRendererInfo.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/API/StrokeRendererInfo.cs new file mode 100644 index 0000000..9bd2998 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/API/StrokeRendererInfo.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using DotNetCampus.Inking.Primitive; + +namespace WpfInk; + +public readonly record struct StrokeRendererInfo +{ + public required IReadOnlyList StylusPointCollection { get; init; } + + public required double Width { get; init; } + public required double Height { get; init; } + + public bool FitToCurve { get; init; } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/Bezier.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/Bezier.cs new file mode 100644 index 0000000..b735a30 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/Bezier.cs @@ -0,0 +1,587 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +using System; +using System.Diagnostics; +using System.Windows; +using System.Windows.Input; +using System.Collections.Generic; + +using MS.Internal.Ink.InkSerializedFormat; +using WpfInk.PresentationCore.System.Windows; +using WpfInk.PresentationCore.System.Windows.Input.Stylus; + +namespace MS.Internal.Ink +{ + /// + /// Bezier curve generation class + /// + internal class Bezier + { + /// + /// Default constructor + /// + public Bezier() { } + + /// + /// Construct bezier control points from points + /// + /// Original StylusPointCollection + /// Fitting error + /// Whether the algorithm succeeded + internal bool ConstructBezierState(StylusPointCollection stylusPoints, double fitError) + { + // If the point count is zero, the curve cannot be constructed + if ((null == stylusPoints) || (stylusPoints.Count == 0)) + return false; + + // Compile list of distinct points and their nodes + CuspData dat = new CuspData(); + dat.Analyze(stylusPoints, + fitError /*typically zero*/); + + return ConstructFromData(dat, fitError); + } + + /// + /// Flatten bezier with a given resolution + /// + /// tolerance + internal List Flatten(double tolerance) + { + List points = new List(); + + // First point + Vector vector = GetBezierPoint(0); + points.Add(new Point(vector.X, vector.Y)); + + int last = this.BezierPointCount - 4; + + if (0 <= last) + { + // Tolerance needs to be non-zero positive + if (tolerance < DoubleUtil.DBL_EPSILON) + tolerance = DoubleUtil.DBL_EPSILON; + + // Flatten individual segments + for (int i = 0; i <= last; i += 3) + FlattenSegment(i, tolerance, points); + } + + //convert from himetric to Avalon + for (int x = 0; x < points.Count; x++) + { + Point p = points[x]; + p.X *= StrokeCollectionSerializer.HimetricToAvalonMultiplier; + p.Y *= StrokeCollectionSerializer.HimetricToAvalonMultiplier; + points[x] = p; + } + + return points; + } + + + /// + /// Extend the current bezier segment if possible + /// + /// Fitting error sqaure + /// Data points + /// Starting index + /// NExt cusp index + /// Index of the last index, updated here + /// Whether there is a cusp at the end + /// Whether end of the stroke is reached + /// Whether the the segment was extended + private bool ExtendingRange(double error, CuspData data, int from, int next_cusp, ref int to, ref bool cusp, ref bool done) + { + to++; + cusp = true; // Presumed guilty + done = to >= data.Count - 1; + if (done) + { + to = data.Count - 1; + cusp = true; + return false; + } + + cusp = to >= next_cusp; + if (cusp) + { + to = next_cusp; + return false; + } + + Debug.Assert(to - from >= 4); + int d = (to - from) / 4; + int[] i = { from, from + d, (to + from) / 2, to - d, to }; + + // Test for "cubicness" + return CoCubic(data, i, error); + } + + + /// + /// Add a bezier segment to the bezier buffer + /// + /// In: Data points + /// In: Index of the first point + /// In: Unit tangent vector at the start + /// In: Index of the last point, updated here + /// In: Unit tangent vector at the end + /// True if the segment was added + private bool AddBezierSegment(CuspData data, int from, ref Vector tanStart, int to, ref Vector tanEnd) + { + switch (to - from) + { + case 1: + AddLine(data, from, to); + return true; + + case 2: + AddParabola(data, from); + return true; + } + + // We have at least 4 points, compute a least squares cubic + return AddLeastSquares(data, from, ref tanStart, to, ref tanEnd); + } + + + /// + /// Construct bezier curve from data points + /// + /// In: Data points + /// In: tolerated error + /// Whether bezier construction is possible + private bool ConstructFromData(CuspData data, double fitError) + { + // Check for empty stroke + if (data.Count < 2) + { + return false; + } + + // Add the first point + AddBezierPoint(data.XY(0)); + + // Special cases - 2 or 3 points + if (data.Count == 3) + { + AddParabola(data, 0); + return true; + } + else if (data.Count == 2) + { + AddLine(data, 0, 1); + return true; + } + + // For default case error passed in will be 0. + // 3% is the default value + if (DoubleUtil.DBL_EPSILON > fitError) + fitError = 0.03f * (data.Distance() * StrokeCollectionSerializer.HimetricToAvalonMultiplier); + + data.SetTanLinks(0.5f * fitError); + + // otherwise use the value specified in the drawing attribute + // get (error)^2 + fitError *= (fitError); + + bool done = false; + int to = 0; + int next_cusp = 0; + int prev_cusp = 0; + bool is_a_cusp = true; + Vector tanEnd = new Vector(0, 0); + Vector tanStart = new Vector(0, 0); + + for (int from = 0; !done; from = to) + { + if (is_a_cusp) + { + prev_cusp = next_cusp; + next_cusp = data.GetNextCusp(from); + if (!data.Tangent(ref tanStart, from, prev_cusp, next_cusp, false, true)) + { + return false; + } + } + else + { + tanStart.X = -tanEnd.X; + tanStart.Y = -tanEnd.Y; + } + + to = from + 3; + + // No meat in this loop, just extending the index range + while (ExtendingRange(fitError, data, from, next_cusp, ref to, ref is_a_cusp, ref done)) ; + + // Find the tangent + if (!data.Tangent(ref tanEnd, to, prev_cusp, next_cusp, true, is_a_cusp)) + { + return false; + } + + // Add bezier segment + if (!AddBezierSegment(data, from, ref tanStart, to, ref tanEnd)) + { + return false; + } + } + + return true; + } + + + /// + /// Add parabola to the bezier + /// + /// In: Data points + /// In: The index of the parabola's first point + private void AddParabola(CuspData data, int from) + { + /* Denote s = 1-t. We construct the parabola with Bezier points A,B,C that + goes thru the point P at parameter value t, that is + P = s^2A + 2stB + t^2C + + We know A and C, and we solve for B: + B = (P - s^2A - t^2C) / 2st. + + Elevating the degree to cubic replaces B with 2 points, the first at + 2B/3 + A/3, and the second at 2B/3 + C/3. + + That is, one point at + (P/(st) - Ct/s + A(-s/t + 1)) / 3 + and the other point at + (P/(st) + C(-t/s + 1) - As/t) / 3 + */ + // By the way the nodes were constructed: + //ASSERT(data.Node(from+2) - data.Node(from) > + // data.Node(from+1) - data.Node(from)); + double t = (data.Node(from + 1) - data.Node(from)) / (data.Node(from + 2) - data.Node(from)); + double s = 1 - t; + + if (t < .001 || s < .001) + { + // A straight line will be a better approximation + AddLine(data, from, from + 2); + return; + } + + double tt = 1 / t; + double ss = 1 / s; + const double third = 1.0d / 3.0d; + Vector P = (tt * ss) * data.XY(from + 1); + Vector B = third * (P + (1 - s * tt) * data.XY(from) - (t * ss) * data.XY(from + 1)); + + AddBezierPoint(B); + B = third * (P - (s * tt) * data.XY(from) + (1 - t * ss) * data.XY(from + 2)); + AddBezierPoint(B); + AddSegmentPoint(data, from + 2); + } + + + /// + /// Add Line to the bezier + /// + /// In: Data points + /// In: The index of the line's first point + /// In: The index of the line's last point + private void AddLine(CuspData data, int from, int to) + { + const double third = 1.0d / 3.0d; + + AddBezierPoint((2 * data.XY(from) + data.XY(to)) * third); + AddBezierPoint((data.XY(from) + 2 * data.XY(to)) * third); + AddSegmentPoint(data, to); + } + + + /// + /// Add least square fit curve to the bezier + /// + /// In: Data points + /// In: Index of the first point + /// In: Unit tangent vector at the start + /// In: Index of the last point, updated here + /// In: Unit tangent vector at the end + /// Return true segment added + private bool AddLeastSquares(CuspData data, int from, ref Vector V, int to, ref Vector W) + { + /* To do: When there is a cusp at either one of the ends, we'll get a + better approximation if we use a construction without a prescribed + tangent there */ + /* + The Bezier points of this segment are A, A+sV, B+uW, and B, where A,B are the + endpoints, and V,W are the end tangents. For the node tj, denote f0j=(1-tj)^3, + f1j=3(1-tj)^2tj, f2j=3(1-tj)tj^2, f3j=tj^3. Let Pj be the jth point. + We are lookig for s,u that minimize + Sum(A*f0j + (A+sV)*f1j + (B+uW)*f2j + B*f3j - Pj)^2. + + Equate the partial derivatives of this w.r.t. s and u to 0: + Sum(A*f0j + (A+sV)*f1j + (B+uW)*f2j + B*f3j - Pj)*(V*f1j)=0 + Sum(A*f0j + (A+sV)*f1j + (B+uW)*f2j + B*f3j - Pj)*(W*f2j)=0 + hence + + s*Sum(V*V*f1j*f1j) + u*Sum(W*V*f1j*f2j)= -Sum(A*(f0j+f1j) + B*(f2j+f3j) - Pj)*V*f1j + s*Sum(V*W*f1j*f2j) + u*Sum(W*W*f2j*f2j)= -Sum(A*(f0j+f1j) + B*(f2j+f3j) - Pj)*W*f2j + + so the equations are + s*a11 + u*a12 = b1 + s*a12 * u*a22 = b2 + + with + a11 = W*W*Sum(f1j^2), a22 = V*V*Sum(f2j^2), a12 = W*V*Sum(f1j*f2j) + b1 = -V*A*Sum(f0j + f1j)*f1j - V*B*Sum(f2j + f3j)*f1j + Sum(f1j*Pj*V) + b2 = -W*A*Sum(f0j + f1j)*f2j - W*B*Sum(f2j + f3j)*f2j + Sum(f2j*Pj*W) + + V and W ae unit vectors, so V*V = W*W = 1. + For computational efficiency, we will break b1 and b2 into 3 sums each, and add + them up at the end + + The solution is + s = (b1*a22 - b2*a12) / det + u = (b2*a11 - b1*a12) / det + where det = a11*a22 - a22^2 + */ + // Compute the coefficients + double a11 = 0, a12 = 0, a22 = 0, b1 = 0, b2 = 0; + double b11 = 0, b12 = 0, b21 = 0, b22 = 0; + + for (int j = checked(from + 1); j < to; j++) + { + // By the way the nodes were constructed - + Debug.Assert(data.Node(to) - data.Node(from) > data.Node(j) - data.Node(from)); + double tj = (data.Node(j) - data.Node(from)) / (data.Node(to) - data.Node(from)); + double tj2 = tj * tj; + double rj = 1 - tj; + double rj2 = rj * rj; + + double f0j = rj2 * rj; + double f1j = 3 * rj2 * tj; + double f2j = 3 * rj * tj2; + double f3j = tj2 * tj; + + a11 += f1j * f1j; + a22 += f2j * f2j; + a12 += f1j * f2j; + + b11 -= (f0j + f1j) * f1j; + b12 -= (f2j + f3j) * f1j; + b1 += f1j * (data.XY(j) * V); + + b21 -= (f0j + f1j) * f2j; + b22 -= (f2j + f3j) * f2j; + b2 += f2j * (data.XY(j) * W); + } + + a12 *= (V * W); + b1 += ((V * data.XY(from)) * b11 + (V * data.XY(to)) * b12); + b2 += ((W * data.XY(from)) * b21 + (W * data.XY(to)) * b22); + + // Solve the equations + double s = b1 * a22 - b2 * a12; + double u = b2 * a11 - b1 * a12; + double det = a11 * a22 - a12 * a12; + bool accept = (Math.Abs(det) > Math.Abs(s) * DoubleUtil.DBL_EPSILON && + Math.Abs(det) > Math.Abs(u) * DoubleUtil.DBL_EPSILON); + + if (accept) + { + s /= det; + u /= det; + + // We'll only accept large enough positive solutions + accept = s > 1.0e-6 && u > 1.0e-6; + } + + if (!accept) + s = u = (data.Node(to) - data.Node(from)) / 3; + + AddBezierPoint(data.XY(from) + s * V); + AddBezierPoint(data.XY(to) + u * W); + AddSegmentPoint(data, to); + return true; + } + + + /// + /// Checks whether five points are co-cubic within tolerance + /// + /// In: Data points + /// In: Array of 5 indices + /// In: tolerated error - squared + /// Return true if extended + private static bool CoCubic(CuspData data, int[] i, double fitError) + { + /* Our error estimate is (t[4]-t[0])^4 times the 4th divided difference + * of the points with resect to the nodes. The divided difference is + * equal to Sum(c(i)*p[i]), where c(i)=Product(t[i]-t[j]: j != i) + * (See Conte & deBoor's Elementary Numerical Analysis, Excercise 2.2-1). + * We multiply each factor in the product by t[4]-t[0]. + */ + double d04 = data.Node(i[4]) - data.Node(i[0]); + double d01 = d04 / (data.Node(i[1]) - data.Node(i[0])); + double d02 = d04 / (data.Node(i[2]) - data.Node(i[0])); + double d03 = d04 / (data.Node(i[3]) - data.Node(i[0])); + double d12 = d04 / (data.Node(i[2]) - data.Node(i[1])); + double d13 = d04 / (data.Node(i[3]) - data.Node(i[1])); + double d14 = d04 / (data.Node(i[4]) - data.Node(i[1])); + double d23 = d04 / (data.Node(i[3]) - data.Node(i[2])); + double d24 = d04 / (data.Node(i[4]) - data.Node(i[2])); + double d34 = d04 / (data.Node(i[4]) - data.Node(i[3])); + Vector P = d01 * d02 * d03 * data.XY(i[0]) - + d01 * d12 * d13 * d14 * data.XY(i[1]) + + d02 * d12 * d23 * d24 * data.XY(i[2]) - + d03 * d13 * d23 * d34 * data.XY(i[3]) + + d14 * d24 * d34 * data.XY(i[4]); + + return ((P * P) < fitError); + } + + + /// + /// Add Bezier point to the output buffer + /// + /// In: The point to add + private void AddBezierPoint(Vector point) + { + _bezierControlPoints.Add((Point) point); + } + + + /// + /// Add segment point + /// + /// In: Interpolation data + /// In: The index of the point to add + private void AddSegmentPoint(CuspData data, int index) + { + _bezierControlPoints.Add((Point) data.XY(index)); + } + + + /// + /// Evaluate on a Bezier segment a point at a given parameter + /// + /// Index of Bezier segment's first point + /// Parameter value t + /// Return the point at parameter t on the curve + private Vector DeCasteljau(int iFirst, double t) + { + // Using the de Casteljau algorithm. See "Curves & Surfaces for Computer + // Aided Design" for the theory + double s = 1.0f - t; + + // Level 1 + Vector Q0 = s * GetBezierPoint(iFirst) + t * GetBezierPoint(iFirst + 1); + Vector Q1 = s * GetBezierPoint(iFirst + 1) + t * GetBezierPoint(iFirst + 2); + Vector Q2 = s * GetBezierPoint(iFirst + 2) + t * GetBezierPoint(iFirst + 3); + + // Level 2 + Q0 = s * Q0 + t * Q1; + Q1 = s * Q1 + t * Q2; + + // Level 3 + return s * Q0 + t * Q1; + } + + /// + /// Flatten a Bezier segment within given resolution + /// + /// Index of Bezier segment's first point + /// tolerance + /// + /// + private void FlattenSegment(int iFirst, double tolerance, List points) + { + // We use forward differencing. It is much faster than subdivision + int i, k; + int nPoints = 1; + Vector[] Q = new Vector[4]; + + // The number of points is determined by the "curvedness" of this segment, + // which is a heuristic: it's the maximum of the 2 medians of the triangles + // formed by consecutive Bezier points. Why median? because it is cheaper + // to compute than height. + double rCurv = 0; + + for (i = checked(iFirst + 1); i <= checked(iFirst + 2); i++) + { + // Get the longer median + Q[0] = (GetBezierPoint(i - 1) + GetBezierPoint(i + 1)) * 0.5f - GetBezierPoint(i); + + double r = Q[0].Length; + + if (r > rCurv) + rCurv = r; + } + + // Now we look at the ratio between the medain and the error tolerance. + // the points are collinear then one point - the endpoint - will do. + // Otherwise, since curvature is roughly inverse proportional + // to the square of nPoints, we set nPoints to be the square root of this + // ratio, but not less than 3. + if (rCurv <= 0.5 * tolerance) // Flat segment + { + Vector vector = GetBezierPoint(iFirst + 3); + points.Add(new Point(vector.X, vector.Y)); + return; + } + + // Otherwise we'll have at least 3 points + // Tolerance is assumed to be positive + nPoints = (int) (Math.Sqrt(rCurv / tolerance)) + 3; + if (nPoints > 1000) + nPoints = 1000; // Arbitrary limitation, but... + + // Get the first 4 points on the segment in the buffer + double d = 1.0f / (double) nPoints; + + Q[0] = GetBezierPoint(iFirst); + for (i = 1; i <= 3; i++) + { + Q[i] = DeCasteljau(iFirst, i * d); + points.Add(new Point(Q[i].X, Q[i].Y)); + } + + // Replace points in the buffer with differences of various levels + for (i = 1; i <= 3; i++) + for (k = 0; k <= (3 - i); k++) + Q[k] = Q[k + 1] - Q[k]; + + // Now generate the rest of the points by forward differencing + for (i = 4; i <= nPoints; i++) + { + for (k = 1; k <= 3; k++) + Q[k] += Q[k - 1]; + + points.Add(new Point(Q[3].X, Q[3].Y)); + } + } + /// + /// Returns a single bezier control point at index + /// + /// Index + /// + private Vector GetBezierPoint(int index) + { + return (Vector) _bezierControlPoints[index]; + } + + + /// + /// Count of bezier control points + /// + private int BezierPointCount + { + get { return _bezierControlPoints.Count; } + } + + // Bezier points + private List _bezierControlPoints = new List(); + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/ContourSegment.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/ContourSegment.cs new file mode 100644 index 0000000..d50df24 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/ContourSegment.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +using System; +using System.Windows; +using WpfInk.PresentationCore.System.Windows; + +namespace MS.Internal.Ink +{ + /// + /// A helper structure representing an edge of a contour, where + /// the edge is either a straight segment or an arc of a circle. + /// ContourSegment are alwais directed clockwise (i.e with the contour + /// inner area being on the right side. + /// Used in hit-testing a contour vs another contour. + /// + internal readonly struct ContourSegment + { + /// + /// Constructor for linear segments + /// + /// segment's begin point + /// segment's end point + internal ContourSegment(Point begin, Point end) + { + _begin = begin; + _vector = DoubleUtil.AreClose(begin, end) ? new Vector(0, 0) : (end - begin); + _radius = new Vector(0, 0); + } + + /// + /// Constructor for arcs + /// + /// arc's begin point + /// arc's end point + /// arc's center + internal ContourSegment(Point begin, Point end, Point center) + { + _begin = begin; + _vector = end - begin; + _radius = center - begin; + } + + /// Tells whether the segment is arc or straight + internal bool IsArc { get { return (_radius.X != 0) || (_radius.Y != 0); } } + + /// Returns the begin point of the segment + internal Point Begin { get { return _begin; } } + + /// Returns the end point of the segment + internal Point End { get { return _begin + _vector; } } + + /// Returns the vector from Begin to End + internal Vector Vector { get { return _vector; } } + + /// Returns the vector from Begin to the center of the circle + /// (zero vector for linear segments + internal Vector Radius { get { return _radius; } } + + #region Fields + + private readonly Point _begin; + private readonly Vector _vector; + private readonly Vector _radius; + + #endregion + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/CuspData.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/CuspData.cs new file mode 100644 index 0000000..2235d51 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/CuspData.cs @@ -0,0 +1,548 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +using System; +using System.Diagnostics; +using System.Windows; +using System.Windows.Input; +using System.Collections.Generic; +using MS.Internal.Ink.InkSerializedFormat; +using WpfInk.PresentationCore.System.Windows; +using WpfInk.PresentationCore.System.Windows.Input.Stylus; + +namespace MS.Internal.Ink +{ + internal class CuspData + { + /// + /// Default constructor + /// + internal CuspData() + { + } + + /// + /// Constructs internal data structure from points for doing operations like + /// cusp detection or tangent computation + /// + /// Points to be analyzed + /// Distance between two consecutive distinct points + internal void Analyze(StylusPointCollection stylusPoints, double rSpan) + { + // If the count is less than 1, return + if ((null == stylusPoints) || (stylusPoints.Count == 0)) + return; + + _points = new List(stylusPoints.Count); + _nodes = new List(stylusPoints.Count); + + // Construct the lists of data points and nodes + _nodes.Add(0); + CDataPoint cdp0 = new CDataPoint(); + cdp0.Index = 0; + //convert from Avalon to Himetric + Point point = (Point) stylusPoints[0]; + point.X *= StrokeCollectionSerializer.AvalonToHimetricMultiplier; + point.Y *= StrokeCollectionSerializer.AvalonToHimetricMultiplier; + cdp0.Point = point; + _points.Add(cdp0); + + //drop duplicates + int index = 0; + for (int i = 1; i < stylusPoints.Count; i++) + { + if (!DoubleUtil.AreClose(stylusPoints[i].X, stylusPoints[i - 1].X) || + !DoubleUtil.AreClose(stylusPoints[i].Y, stylusPoints[i - 1].Y)) + { + //this is a unique point, add it + index++; + + CDataPoint cdp = new CDataPoint(); + cdp.Index = index; + + //convert from Avalon to Himetric + Point point2 = (Point) stylusPoints[i]; + point2.X *= StrokeCollectionSerializer.AvalonToHimetricMultiplier; + point2.Y *= StrokeCollectionSerializer.AvalonToHimetricMultiplier; + cdp.Point = point2; + + _points.Insert(index, cdp); + _nodes.Insert(index, _nodes[index - 1] + (XY(index) - XY(index - 1)).Length); + } + } + + SetLinks(rSpan); + } + + /// + /// Set links amongst the points for tangent computation + /// + /// Shortest distance between two distinct points + internal void SetTanLinks(double rError) + { + int count = Count; + + if (rError < 1.0) + rError = 1.0f; + + for (int i = 0; i < count; ++i) + { + // Find a StylusPoint at distance-_span forward + for (int j = i + 1; j < count; j++) + { + if (_nodes[j] - _nodes[i] >= rError) + { + CDataPoint cdp = _points[i]; + cdp.TanNext = j; + _points[i] = cdp; + + CDataPoint cdp2 = _points[j]; + cdp2.TanPrev = i; + _points[j] = cdp2; + break; + } + } + + if (0 > _points[i].TanPrev) + { + for (int j = i - 1; 0 <= j; --j) + { + if (_nodes[i] - _nodes[j] >= rError) + { + CDataPoint cdp = _points[i]; + cdp.TanPrev = j; + _points[i] = cdp; + break; + } + } + } + + if (0 > _points[i].TanNext) + { + CDataPoint cdp = _points[i]; + cdp.TanNext = count - 1; + _points[i] = cdp; + } + + if (0 > _points[i].TanPrev) + { + CDataPoint cdp = _points[i]; + cdp.TanPrev = 0; + _points[i] = cdp; + } + } + } + + + + + /// + /// Return the Index of the next cusp or the + /// Index of the last StylusPoint if no cusp was found + /// + /// Current StylusPoint Index + /// Index into CuspData object for the next cusp + internal int GetNextCusp(int iCurrent) + { + int last = Count - 1; + + if (iCurrent < 0) + return 0; + + if (iCurrent >= last) + return last; + + // Perform a binary search + int s = 0, e = _cusps.Count; + int m = (s + e) / 2; + + while (s < m) + { + if (_cusps[m] <= iCurrent) + s = m; + else + e = m; + + m = (s + e) / 2; + } + + return _cusps[m + 1]; + } + + /// + /// Point at Index i into the cusp data structure + /// + /// Index + /// StylusPoint + /// The Index is within the bounds + internal Vector XY(int i) + { + return new Vector(_points[i].Point.X, _points[i].Point.Y); + } + + + /// + /// Number of points in the internal data structure + /// + internal int Count + { + get { return _points.Count; } + } + + + /// + /// Returns the chord length of the i-th StylusPoint from start of the stroke + /// + /// StylusPoint Index + /// distance + /// The Index is within the bounds + internal double Node(int i) + { + return _nodes[i]; + } + + + /// + /// Returns the Index into original points given an Index into cusp data + /// + /// Cusp data Index + /// Original StylusPoint Index + internal int GetPointIndex(int nodeIndex) + { + return _points[nodeIndex].Index; + } + + + /// + /// Distance + /// + /// distance + internal double Distance() + { + return _dist; + } + + + /// + /// Finds the approximante tangent at a given StylusPoint + /// + /// Tangent vector + /// Index at which the tangent is calculated + /// Index of the previous cusp + /// Index of the next cusp + /// Forward or reverse tangent + /// Whether the current idex is a cusp StylusPoint + /// Return whether the tangent computation succeeded + internal bool Tangent(ref Vector ptT, int nAt, int nPrevCusp, int nNextCusp, bool bReverse, bool bIsCusp) + { + // Tangent is computed as the unit vector along + // PT = (P1 - P0) + (P2 - P0) + (P3 - P0) + // => PT = P1 + P2 + P3 - 3 * P0 + int i_1, i_2, i_3; + + if (bIsCusp) + { + if (bReverse) + { + i_1 = _points[nAt].TanPrev; + if (i_1 < nPrevCusp || (0 > i_1)) + { + i_2 = nPrevCusp; + i_1 = (i_2 + nAt) / 2; + } + else + { + i_2 = _points[i_1].TanPrev; + if (i_2 < nPrevCusp) + i_2 = nPrevCusp; + } + } + else + { + i_1 = _points[nAt].TanNext; + if (i_1 > nNextCusp || (0 > i_1)) + { + i_2 = nNextCusp; + i_1 = (i_2 + nAt) / 2; + } + else + { + i_2 = _points[i_1].TanNext; + if (i_2 > nNextCusp) + i_2 = nNextCusp; + } + } + ptT = XY(i_1) + 0.5 * XY(i_2) - 1.5 * XY(nAt); + } + else + { + Debug.Assert(bReverse); + i_1 = nAt; + i_2 = _points[nAt].TanPrev; + if (i_2 < nPrevCusp) + { + i_3 = nPrevCusp; + i_2 = (i_3 + i_1) / 2; + } + else + { + i_3 = _points[i_2].TanPrev; + if (i_3 < nPrevCusp) + i_3 = nPrevCusp; + } + + nAt = _points[nAt].TanNext; + if (nAt > nNextCusp) + nAt = nNextCusp; + + ptT = XY(i_1) + XY(i_2) + 0.5 * XY(i_3) - 2.5 * XY(nAt); + } + + if (DoubleUtil.IsZero(ptT.LengthSquared)) + { + return false; + } + + ptT.Normalize(); + return true; + } + + /// + /// This "curvature" is not the theoretical curvature. it is a number between + /// 0 and 2 that is defined as 1 - cos(angle between segments) at this StylusPoint. + /// + /// Previous data StylusPoint Index + /// Current data StylusPoint Index + /// Next data StylusPoint Index + /// "Curvature" + private double GetCurvature(int iPrev, int iCurrent, int iNext) + { + Vector V = XY(iCurrent) - XY(iPrev); + Vector W = XY(iNext) - XY(iCurrent); + double r = V.Length * W.Length; + + if (DoubleUtil.IsZero(r)) + return 0; + + return 1 - (V * W) / r; + } + + + /// + /// Find all cusps for the stroke + /// + private void FindAllCusps() + { + // Clear the existing cusp indices + _cusps.Clear(); + + // There is nothing to find out from + if (1 > this.Count) + return; + + // First StylusPoint is always a cusp + _cusps.Add(0); + + int iPrev = 0, iNext = 0, iCuspPrev = 0; + + // Find the next StylusPoint for Index 0 + // The following check will cover coincident points, stroke with + // less than 3 points + if (!FindNextAndPrev(0, iCuspPrev, ref iPrev, ref iNext)) + { + // Point count is zero, thus, there can't be any cusps + if (0 == this.Count) + _cusps.Clear(); + else if (1 < this.Count) // Last StylusPoint is always a cusp + _cusps.Add(iNext); + + return; + } + + // Start the algorithm with the next StylusPoint + int iPoint = iNext; + double rCurv = 0; + + // Check all the points on the chord of the stroke + while (FindNextAndPrev(iPoint, iCuspPrev, ref iPrev, ref iNext)) + { + // Find the curvature at iPoint + rCurv = GetCurvature(iPrev, iPoint, iNext); + + /* + We'll look at every StylusPoint where rPrevCurv is a local maximum, and the + curvature is more than the noise threashold. If we're near the beginning + of the stroke then we'll ignore it and carry on. If we're near the end + then we'll skip to the end. Otherwise, we'll flag it as a cusp if it + deviates is significantly from the curvature at nearby points, forward + and backward + */ + if (0.80 < rCurv) + { + double rMaxCurv = rCurv; + int iMaxCurv = iPoint; + int m = 0, k = 0; + + if (!FindNextAndPrev(iNext, iCuspPrev, ref k, ref m)) + { + // End of the stroke has been reached + break; + } + + for (int i = iPrev + 1; (i <= m) && FindNextAndPrev(i, iCuspPrev, ref iPrev, ref iNext); ++i) + { + rCurv = GetCurvature(iPrev, i, iNext); + if (rCurv > rMaxCurv) + { + rMaxCurv = rCurv; + iMaxCurv = i; + } + } + + // Save the Index with max curvature + _cusps.Add(iMaxCurv); + + // Continue the search with next StylusPoint + iPoint = m + 1; + iCuspPrev = iMaxCurv; + } + else if (0.035 > rCurv) + { + // If the angle is less than 15 degree, skip the segment + iPoint = iNext; + } + else + ++iPoint; + } + + // If everything went right, add the last StylusPoint to the list of cusps + _cusps.Add(this.Count - 1); + } + + + /// + /// Finds the next and previous data StylusPoint Index for the given data Index + /// + /// Index at which the computation is performed + /// Previous cusp + /// Previous data Index + /// Next data Index + /// Returns true if the end has NOT been reached. + private bool FindNextAndPrev(int iPoint, int iPrevCusp, ref int iPrev, ref int iNext) + { + bool bHasMore = true; + + if (iPoint >= Count) + { + bHasMore = false; + iPoint = Count - 1; + } + + // Find a StylusPoint at distance-_span forward + for (iNext = checked(iPoint + 1); iNext < Count; ++iNext) + if (_nodes[iNext] - _nodes[iPoint] >= _span) + break; + + if (iNext >= Count) + { + bHasMore = false; + iNext = Count - 1; + } + + for (iPrev = checked(iPoint - 1); iPrevCusp <= iPrev; --iPrev) + if (_nodes[iPoint] - _nodes[iPrev] >= _span) + break; + + if (iPrev < 0) + iPrev = 0; + + return bHasMore; + } + + + /// + /// + /// + /// + /// + /// + private static void UpdateMinMax(double a, ref double rMin, ref double rMax) + { + rMin = Math.Min(rMin, a); + rMax = Math.Max(a, rMax); + } + /// + /// Sets up the internal data structure to construct chain of points + /// + /// Shortest distance between two distinct points + private void SetLinks(double rSpan) + { + // NOP, if there is only one StylusPoint + int count = Count; + + if (2 > count) + return; + + // Set up the links to next and previous probe + double rL = XY(0).X; + double rT = XY(0).Y; + double rR = rL; + double rB = rT; + + for (int i = 0; i < count; ++i) + { + UpdateMinMax(XY(i).X, ref rL, ref rR); + UpdateMinMax(XY(i).Y, ref rT, ref rB); + } + + rR -= rL; + rB -= rT; + _dist = Math.Abs(rR) + Math.Abs(rB); + if (false == DoubleUtil.IsZero(rSpan)) + _span = rSpan; + else if (0 < _dist) + { + /*** + _nodes[count - 1] at this StylusPoint contains the length of the stroke. + _dist is the half peripheri of the bounding box of the stroke. + The idea here is that the Length/_dist is somewhat analogous to the + "fractal dimension" of the stroke (or in other words, how much the stroke + winds.) + Length/count is the average distance between two consequitive points + on the stroke. Thus, this average distance is multiplied by the winding + factor. + If the stroke were a PURE straight line across the diagone of a square, + Lenght/Dist will be approximately 1.41. And if there were one pixel per + co-ordinate, the span would have been 1.41, which works fairly well in + cusp detection + ***/ + _span = 0.75f * (_nodes[count - 1] * _nodes[count - 1]) / (count * _dist); + } + + if (_span < 1.0) + _span = 1.0f; + + FindAllCusps(); + } + + + private List _points; + private List _nodes; + private double _dist = 0; + private List _cusps = new List(); + + // Parameters governing the cusp detection algorithm + // Distance between probes for curvature checking + private double _span = 3; // Default span + + struct CDataPoint + { + public Point Point; // Point (coordinates are double) + public int Index; // Index into the original array + public int TanPrev; // Previous StylusPoint Index for tangent computation + public int TanNext; // Next StylusPoint Index for tangent computation + }; + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/DrawingFlags.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/DrawingFlags.cs new file mode 100644 index 0000000..1745416 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/DrawingFlags.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +using System; + +namespace WpfInk.PresentationCore.System.Windows.Ink +{ + /// Flag values which can help the renderer decide how to + /// draw the ink strokes + [Flags] + internal enum DrawingFlags + { + /// The stroke should be drawn as a polyline + Polyline = 0x00000000, + /// The stroke should be fit to a curve, such as a bezier. + FitToCurve = 0x00000001, + /// The stroke should be rendered by subtracting its rendering values + /// from those on the screen + SubtractiveTransparency = 0x00000002, + /// Ignore any stylus pressure information when rendering + IgnorePressure = 0x00000004, + /// The stroke should be rendered with anti-aliased edges + AntiAliased = 0x00000010, + /// Ignore any stylus rotation information when rendering + IgnoreRotation = 0x00000020, + /// Ignore any stylus angle information when rendering + IgnoreAngle = 0x00000040, + }; +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/EllipticalNodeOperations.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/EllipticalNodeOperations.cs new file mode 100644 index 0000000..3cc918b --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/EllipticalNodeOperations.cs @@ -0,0 +1,836 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Input; +using System.Diagnostics; +using WpfInk.PresentationCore.System.Windows; +using WpfInk.PresentationCore.System.Windows.Ink; +using WpfInk.WindowsBase.System.Windows.Media; + +namespace MS.Internal.Ink +{ + /// + /// StrokeNodeOperations implementation for elliptical nodes + /// + internal class EllipticalNodeOperations : StrokeNodeOperations + { + /// + /// Constructor + /// + /// + internal EllipticalNodeOperations(StylusShape nodeShape) + : base(nodeShape) + { + System.Diagnostics.Debug.Assert((nodeShape != null) && nodeShape.IsEllipse); + + _radii = new Size(nodeShape.Width * 0.5, nodeShape.Height * 0.5); + + // All operations with ellipses become simple(r) if transfrom ellipses into circles. + // Use the max of the radii for the radius of the circle + _radius = Math.Max(_radii.Width, _radii.Height); + + // Compute ellipse-to-circle and circle-to-elliipse transforms. The former is used + // in hit-testing operations while the latter is used when computing vertices of + // a quadrangle connecting two ellipses + _transform = nodeShape.Transform; + _nodeShapeToCircle = _transform; + + Debug.Assert(_nodeShapeToCircle.HasInverse, "About to invert a non-invertible transform"); + _nodeShapeToCircle.Invert(); + if (DoubleUtil.AreClose(_radii.Width, _radii.Height)) + { + _circleToNodeShape = _transform; + } + else + { + // Reverse the rotation + if (false == DoubleUtil.IsZero(nodeShape.Rotation)) + { + _nodeShapeToCircle.Rotate(-nodeShape.Rotation); + Debug.Assert(_nodeShapeToCircle.HasInverse, "Just rotated an invertible transform and produced a non-invertible one"); + } + + // Scale to enlarge + double sx, sy; + if (_radii.Width > _radii.Height) + { + sx = 1; + sy = _radii.Width / _radii.Height; + } + else + { + sx = _radii.Height / _radii.Width; + sy = 1; + } + _nodeShapeToCircle.Scale(sx, sy); + Debug.Assert(_nodeShapeToCircle.HasInverse, "Just scaled an invertible transform and produced a non-invertible one"); + + _circleToNodeShape = _nodeShapeToCircle; + _circleToNodeShape.Invert(); + } + } + + /// + /// This is probably not the best (design-wise) but the cheapest way to tell + /// EllipticalNodeOperations from all other implementations of node operations. + /// + internal override bool IsNodeShapeEllipse { get { return true; } } + + /// + /// Finds connecting points for a pair of stroke nodes + /// + /// a node to connect + /// another node, next to beginNode + /// connecting quadrangle + internal override Quad GetConnectingQuad(in StrokeNodeData beginNode, in StrokeNodeData endNode) + { + if (beginNode.IsEmpty || endNode.IsEmpty || DoubleUtil.AreClose(beginNode.Position, endNode.Position)) + { + return Quad.Empty; + } + + // Get the vector between the node positions + Vector spine = endNode.Position - beginNode.Position; + if (_nodeShapeToCircle.IsIdentity == false) + { + spine = _nodeShapeToCircle.Transform(spine); + } + + double beginRadius = _radius * beginNode.PressureFactor; + double endRadius = _radius * endNode.PressureFactor; + + // Get the vector and the distance between the node positions + double distanceSquared = spine.LengthSquared; + double delta = endRadius - beginRadius; + double deltaSquared = DoubleUtil.IsZero(delta) ? 0 : (delta * delta); + + if (DoubleUtil.LessThanOrClose(distanceSquared, deltaSquared)) + { + // One circle is contained within the other + return Quad.Empty; + } + + // Thus, at this point, distance > 0, which avoids the DivideByZero error + // Also, here, distanceSquared > deltaSquared + // Thus, 0 <= rSin < 1 + // Get the components of the radius vectors + double distance = Math.Sqrt(distanceSquared); + + spine /= distance; + + Vector rad = spine; + + // Turn left + double temp = rad.Y; + rad.Y = -rad.X; + rad.X = temp; + + Vector vectorToLeftTangent, vectorToRightTangent; + double rSinSquared = deltaSquared / distanceSquared; + + if (DoubleUtil.IsZero(rSinSquared)) + { + vectorToLeftTangent = rad; + vectorToRightTangent = -rad; + } + else + { + rad *= Math.Sqrt(1 - rSinSquared); + spine *= Math.Sqrt(rSinSquared); + if (beginNode.PressureFactor < endNode.PressureFactor) + { + spine = -spine; + } + + vectorToLeftTangent = spine + rad; + vectorToRightTangent = spine - rad; + } + + // Get the common tangent points + + if (_circleToNodeShape.IsIdentity == false) + { + vectorToLeftTangent = _circleToNodeShape.Transform(vectorToLeftTangent); + vectorToRightTangent = _circleToNodeShape.Transform(vectorToRightTangent); + } + + return new Quad(beginNode.Position + (vectorToLeftTangent * beginRadius), + endNode.Position + (vectorToLeftTangent * endRadius), + endNode.Position + (vectorToRightTangent * endRadius), + beginNode.Position + (vectorToRightTangent * beginRadius)); + } + + /// + /// + /// + /// + /// + /// + internal override IEnumerable GetContourSegments(StrokeNodeData node, Quad quad) + { + System.Diagnostics.Debug.Assert(node.IsEmpty == false); + + if (quad.IsEmpty) + { + Point point = node.Position; + point.X += _radius; + yield return new ContourSegment(point, point, node.Position); + } + else if (_nodeShapeToCircle.IsIdentity) + { + yield return new ContourSegment(quad.A, quad.B); + yield return new ContourSegment(quad.B, quad.C, node.Position); + yield return new ContourSegment(quad.C, quad.D); + yield return new ContourSegment(quad.D, quad.A); + } + } + + /// + /// ISSUE-2004/06/15- temporary workaround to avoid hit-testing ellipses with ellipses + /// + /// + /// + /// + internal override IEnumerable GetNonBezierContourSegments(StrokeNodeData beginNode, StrokeNodeData endNode) + { + Quad quad = beginNode.IsEmpty ? Quad.Empty : base.GetConnectingQuad(beginNode, endNode); + return base.GetContourSegments(endNode, quad); + } + + + /// + /// Hit-tests a stroke segment defined by two nodes against a linear segment. + /// + /// Begin node of the stroke segment to hit-test. Can be empty (none) + /// End node of the stroke segment + /// Pre-computed quadrangle connecting the two nodes. + /// Can be empty if the begion node is empty or when one node is entirely inside the other + /// an end point of the hitting linear segment + /// an end point of the hitting linear segment + /// true if the hitting segment intersect the contour comprised of the two stroke nodes + internal override bool HitTest( + in StrokeNodeData beginNode, in StrokeNodeData endNode, Quad quad, Point hitBeginPoint, Point hitEndPoint) + { + StrokeNodeData bigNode, smallNode; + if (beginNode.IsEmpty || (quad.IsEmpty && (endNode.PressureFactor > beginNode.PressureFactor))) + { + // Need to test one node only + bigNode = endNode; + smallNode = StrokeNodeData.Empty; + } + else + { + // In this case the size doesn't matter. + bigNode = beginNode; + smallNode = endNode; + } + + // Compute the positions of the involved points relative to bigNode. + Vector hitBegin = hitBeginPoint - bigNode.Position; + Vector hitEnd = hitEndPoint - bigNode.Position; + + // If the node shape is an ellipse, transform the scene to turn the shape to a circle + if (_nodeShapeToCircle.IsIdentity == false) + { + hitBegin = _nodeShapeToCircle.Transform(hitBegin); + hitEnd = _nodeShapeToCircle.Transform(hitEnd); + } + + bool isHit = false; + + // Hit-test the big node + double bigRadius = _radius * bigNode.PressureFactor; + Vector nearest = GetNearest(hitBegin, hitEnd); + if (nearest.LengthSquared <= (bigRadius * bigRadius)) + { + isHit = true; + } + else if (quad.IsEmpty == false) + { + // Hit-test the other node + Vector spineVector = smallNode.Position - bigNode.Position; + if (_nodeShapeToCircle.IsIdentity == false) + { + spineVector = _nodeShapeToCircle.Transform(spineVector); + } + double smallRadius = _radius * smallNode.PressureFactor; + nearest = GetNearest(hitBegin - spineVector, hitEnd - spineVector); + if ((nearest.LengthSquared <= (smallRadius * smallRadius)) || HitTestQuadSegment(quad, hitBeginPoint, hitEndPoint)) + { + isHit = true; + } + } + + return isHit; + } + + /// + /// Hit-tests a stroke segment defined by two nodes against another stroke segment. + /// + /// Begin node of the stroke segment to hit-test. Can be empty (none) + /// End node of the stroke segment + /// Pre-computed quadrangle connecting the two nodes. + /// Can be empty if the begion node is empty or when one node is entirely inside the other + /// a collection of basic segments outlining the hitting contour + /// true if the contours intersect or overlap + internal override bool HitTest( + in StrokeNodeData beginNode, in StrokeNodeData endNode, Quad quad, IEnumerable hitContour) + { + StrokeNodeData bigNode, smallNode; + double bigRadiusSquared, smallRadiusSquared = 0; + Vector spineVector; + if (beginNode.IsEmpty || (quad.IsEmpty && (endNode.PressureFactor > beginNode.PressureFactor))) + { + // Need to test one node only + bigNode = endNode; + smallNode = StrokeNodeData.Empty; + spineVector = new Vector(); + } + else + { + // In this case the size doesn't matter. + bigNode = beginNode; + smallNode = endNode; + + smallRadiusSquared = _radius * smallNode.PressureFactor; + smallRadiusSquared *= smallRadiusSquared; + + // Find position of smallNode relative to the bigNode. + spineVector = smallNode.Position - bigNode.Position; + // If the node shape is an ellipse, transform the scene to turn the shape to a circle + if (_nodeShapeToCircle.IsIdentity == false) + { + spineVector = _nodeShapeToCircle.Transform(spineVector); + } + } + + bigRadiusSquared = _radius * bigNode.PressureFactor; + bigRadiusSquared *= bigRadiusSquared; + + bool isHit = false; + + // When hit-testing a contour against another contour, like in this case, + // the default implementation checks whether any edge (segment) of the hitting + // contour intersects with the contour of the ink segment. But this doesn't cover + // the case when the ink segment is entirely inside of the hitting segment. + // The bool variable isInside is used here to track that case. It answers the question + // 'Is ink contour inside if the hitting contour?'. It's initialized to 'true" + // and then verified for each edge of the hitting contour until there's a hit or + // until it's false. + bool isInside = true; + + foreach (ContourSegment hitSegment in hitContour) + { + if (hitSegment.IsArc) + { + // ISSUE-2004/06/15-vsmirnov - ellipse vs arc hit-testing is not implemented + // and currently disabled in ErasingStroke + } + else + { + // Find position of the hitting segment relative to bigNode transformed to circle. + Vector hitBegin = hitSegment.Begin - bigNode.Position; + Vector hitEnd = hitBegin + hitSegment.Vector; + if (_nodeShapeToCircle.IsIdentity == false) + { + hitBegin = _nodeShapeToCircle.Transform(hitBegin); + hitEnd = _nodeShapeToCircle.Transform(hitEnd); + } + + // Hit-test the big node + Vector nearest = GetNearest(hitBegin, hitEnd); + if (nearest.LengthSquared <= bigRadiusSquared) + { + isHit = true; + break; + } + + // Hit-test the other node + if (quad.IsEmpty == false) + { + nearest = GetNearest(hitBegin - spineVector, hitEnd - spineVector); + if ((nearest.LengthSquared <= smallRadiusSquared) || + HitTestQuadSegment(quad, hitSegment.Begin, hitSegment.End)) + { + isHit = true; + break; + } + } + + // While there's still a chance to find the both nodes inside the hitting contour, + // continue checking on position of the endNode relative to the edges of the hitting contour. + if (isInside && + (WhereIsVectorAboutVector(endNode.Position - hitSegment.Begin, hitSegment.Vector) != HitResult.Right)) + { + isInside = false; + } + } + } + + return (isHit || isInside); + } + + /// + /// Cut-test ink segment defined by two nodes and a connecting quad against a linear segment + /// + /// Begin node of the ink segment + /// End node of the ink segment + /// Pre-computed quadrangle connecting the two ink nodes + /// egin point of the hitting segment + /// End point of the hitting segment + /// Exact location to cut at represented by StrokeFIndices + internal override StrokeFIndices CutTest( + in StrokeNodeData beginNode, in StrokeNodeData endNode, Quad quad, Point hitBeginPoint, Point hitEndPoint) + { + // Compute the positions of the involved points relative to the endNode. + Vector spineVector = beginNode.IsEmpty ? new Vector(0, 0) : (beginNode.Position - endNode.Position); + Vector hitBegin = hitBeginPoint - endNode.Position; + Vector hitEnd = hitEndPoint - endNode.Position; + + // If the node shape is an ellipse, transform the scene to turn the shape to a circle + if (_nodeShapeToCircle.IsIdentity == false) + { + spineVector = _nodeShapeToCircle.Transform(spineVector); + hitBegin = _nodeShapeToCircle.Transform(hitBegin); + hitEnd = _nodeShapeToCircle.Transform(hitEnd); + } + + StrokeFIndices result = StrokeFIndices.Empty; + + // Hit-test the end node + double beginRadius = 0, endRadius = _radius * endNode.PressureFactor; + Vector nearest = GetNearest(hitBegin, hitEnd); + if (nearest.LengthSquared <= (endRadius * endRadius)) + { + result.EndFIndex = StrokeFIndices.AfterLast; + result.BeginFIndex = beginNode.IsEmpty ? StrokeFIndices.BeforeFirst : 1; + } + if (beginNode.IsEmpty == false) + { + // Hit-test the first node + beginRadius = _radius * beginNode.PressureFactor; + nearest = GetNearest(hitBegin - spineVector, hitEnd - spineVector); + if (nearest.LengthSquared <= (beginRadius * beginRadius)) + { + result.BeginFIndex = StrokeFIndices.BeforeFirst; + if (!DoubleUtil.AreClose(result.EndFIndex, StrokeFIndices.AfterLast)) + { + result.EndFIndex = 0; + } + } + } + + // If both nodes are hit or nothing is hit at all, return. + if (result.IsFull || quad.IsEmpty + || (result.IsEmpty && (HitTestQuadSegment(quad, hitBeginPoint, hitEndPoint) == false))) + { + return result; + } + + // Find out whether the {begin, end} segment intersects with the contour + // of the stroke segment {_lastNode, _thisNode}, and find the lower index + // of the fragment to cut out. + if (!DoubleUtil.AreClose(result.BeginFIndex, StrokeFIndices.BeforeFirst)) + { + result.BeginFIndex = ClipTest(-spineVector, beginRadius, endRadius, hitBegin - spineVector, hitEnd - spineVector); + } + + if (!DoubleUtil.AreClose(result.EndFIndex, StrokeFIndices.AfterLast)) + { + result.EndFIndex = 1 - ClipTest(spineVector, endRadius, beginRadius, hitBegin, hitEnd); + } + + if (IsInvalidCutTestResult(result)) + { + return StrokeFIndices.Empty; + } + + return result; + } + + /// + /// CutTest an inking StrokeNode segment (two nodes and a connecting quadrangle) against a hitting contour + /// (represented by an enumerator of Contoursegments). + /// + /// The begin StrokeNodeData + /// The end StrokeNodeData + /// Connecing quadrangle between the begin and end inking node + /// The hitting ContourSegments + /// StrokeFIndices representing the location for cutting + internal override StrokeFIndices CutTest( + in StrokeNodeData beginNode, in StrokeNodeData endNode, Quad quad, IEnumerable hitContour) + { + // Compute the positions of the beginNode relative to the endNode. + Vector spineVector = beginNode.IsEmpty ? new Vector(0, 0) : (beginNode.Position - endNode.Position); + // If the node shape is an ellipse, transform the scene to turn the shape to a circle + if (_nodeShapeToCircle.IsIdentity == false) + { + spineVector = _nodeShapeToCircle.Transform(spineVector); + } + + double beginRadius = 0, endRadius; + double beginRadiusSquared = 0, endRadiusSquared; + + endRadius = _radius * endNode.PressureFactor; + endRadiusSquared = endRadius * endRadius; + if (beginNode.IsEmpty == false) + { + beginRadius = _radius * beginNode.PressureFactor; + beginRadiusSquared = beginRadius * beginRadius; + } + + bool isInside = true; + StrokeFIndices result = StrokeFIndices.Empty; + + foreach (ContourSegment hitSegment in hitContour) + { + if (hitSegment.IsArc) + { + // ISSUE-2004/06/15-vsmirnov - ellipse vs arc hit-testing is not implemented + // and currently disabled in ErasingStroke + } + else + { + Vector hitBegin = hitSegment.Begin - endNode.Position; + Vector hitEnd = hitBegin + hitSegment.Vector; + + // If the node shape is an ellipse, transform the scene to turn + // the shape into circle. + if (_nodeShapeToCircle.IsIdentity == false) + { + hitBegin = _nodeShapeToCircle.Transform(hitBegin); + hitEnd = _nodeShapeToCircle.Transform(hitEnd); + } + + bool isHit = false; + + // Hit-test the end node + Vector nearest = GetNearest(hitBegin, hitEnd); + if (nearest.LengthSquared < endRadiusSquared) + { + isHit = true; + if (!DoubleUtil.AreClose(result.EndFIndex, StrokeFIndices.AfterLast)) + { + result.EndFIndex = StrokeFIndices.AfterLast; + if (beginNode.IsEmpty) + { + result.BeginFIndex = StrokeFIndices.BeforeFirst; + break; + } + if (DoubleUtil.AreClose(result.BeginFIndex, StrokeFIndices.BeforeFirst)) + { + break; + } + } + } + + if ((beginNode.IsEmpty == false) && (!isHit || !DoubleUtil.AreClose(result.BeginFIndex, StrokeFIndices.BeforeFirst))) + { + // Hit-test the first node + nearest = GetNearest(hitBegin - spineVector, hitEnd - spineVector); + if (nearest.LengthSquared < beginRadiusSquared) + { + isHit = true; + if (!DoubleUtil.AreClose(result.BeginFIndex, StrokeFIndices.BeforeFirst)) + { + result.BeginFIndex = StrokeFIndices.BeforeFirst; + if (DoubleUtil.AreClose(result.EndFIndex, StrokeFIndices.AfterLast)) + { + break; + } + } + } + } + + // If both nodes are hit or nothing is hit at all, return. + if (beginNode.IsEmpty || (!isHit && (quad.IsEmpty || + (HitTestQuadSegment(quad, hitSegment.Begin, hitSegment.End) == false)))) + { + if (isInside && (WhereIsVectorAboutVector( + endNode.Position - hitSegment.Begin, hitSegment.Vector) != HitResult.Right)) + { + isInside = false; + } + continue; + } + + isInside = false; + + // Calculate the exact locations to cut. + CalculateCutLocations(spineVector, hitBegin, hitEnd, endRadius, beginRadius, ref result); + + if (result.IsFull) + { + break; + } + } + } + + // + if (!result.IsFull) + { + if (isInside == true) + { + System.Diagnostics.Debug.Assert(result.IsEmpty); + result = StrokeFIndices.Full; + } + else if ((DoubleUtil.AreClose(result.EndFIndex, StrokeFIndices.BeforeFirst)) && (!DoubleUtil.AreClose(result.BeginFIndex, StrokeFIndices.AfterLast))) + { + result.EndFIndex = StrokeFIndices.AfterLast; + } + else if ((DoubleUtil.AreClose(result.BeginFIndex, StrokeFIndices.AfterLast)) && (!DoubleUtil.AreClose(result.EndFIndex, StrokeFIndices.BeforeFirst))) + { + result.BeginFIndex = StrokeFIndices.BeforeFirst; + } + } + + if (IsInvalidCutTestResult(result)) + { + return StrokeFIndices.Empty; + } + + return result; + } + + /// + /// Clip-Testing a circluar inking segment against a linear segment. + /// See http://tabletpc/longhorn/Specs/Rendering%20and%20Hit-Testing%20Ink%20in%20Avalon%20M11.doc section + /// 5.4.4.14 Clip-Testing a Circular Inking Segment against a Linear Segment for details of the algorithm + /// + /// Represent the spine of the inking segment pointing from the beginNode to endNode + /// Radius of the beginNode + /// Radius of the endNode + /// Hitting segment start point + /// Hitting segment end point + /// A double which represents the location for cutting + private static double ClipTest(Vector spineVector, double beginRadius, double endRadius, Vector hitBegin, Vector hitEnd) + { + // First handle the special case when the spineVector is (0,0). In other words, this is the case + // when the stylus stays at the the location but pressure changes. + if (DoubleUtil.IsZero(spineVector.X) && DoubleUtil.IsZero(spineVector.Y)) + { + System.Diagnostics.Debug.Assert(DoubleUtil.AreClose(beginRadius, endRadius) == false); + + Vector nearest = GetNearest(hitBegin, hitEnd); + double radius; + if (nearest.X == 0) + { + radius = Math.Abs(nearest.Y); + } + else if (nearest.Y == 0) + { + radius = Math.Abs(nearest.X); + } + else + { + radius = nearest.Length; + } + return AdjustFIndex((radius - beginRadius) / (endRadius - beginRadius)); + } + + // This change to ClipTest with a point if the two hitting segment are close enough. + if (DoubleUtil.AreClose(hitBegin, hitEnd)) + { + return ClipTest(spineVector, beginRadius, endRadius, hitBegin); + } + + double findex; + Vector hitVector = hitEnd - hitBegin; + + if (DoubleUtil.IsZero(Vector.Determinant(spineVector, hitVector))) + { + // hitVector and spineVector are parallel + findex = ClipTest(spineVector, beginRadius, endRadius, GetNearest(hitBegin, hitEnd)); + System.Diagnostics.Debug.Assert(!double.IsNaN(findex)); + } + else + { + // On the line defined by the segment find point P1Xp, the nearest to the beginNode.Position + double x = GetProjectionFIndex(hitBegin, hitEnd); + Vector P1Xp = hitBegin + (hitVector * x); + if (P1Xp.LengthSquared < (beginRadius * beginRadius)) + { + System.Diagnostics.Debug.Assert(DoubleUtil.IsBetweenZeroAndOne(x) == false); + findex = ClipTest(spineVector, beginRadius, endRadius, (0 > x) ? hitBegin : hitEnd); + System.Diagnostics.Debug.Assert(!double.IsNaN(findex)); + } + else + { + // Find the projection point P of endNode.Position to the line (beginNode.Position, B). + Vector P1P2p = spineVector + GetProjection(-spineVector, P1Xp - spineVector); + + //System.Diagnostics.Debug.Assert(false == DoubleUtil.IsZero(P1P2p.LengthSquared)); + //System.Diagnostics.Debug.Assert(false == DoubleUtil.IsZero(endRadius - beginRadius + P1P2p.Length)); + // There checks are here since if either fail no real solution can be caculated and we may + // as well bail out now and save the caculations that are below. + if (DoubleUtil.IsZero(P1P2p.LengthSquared) || DoubleUtil.IsZero(endRadius - beginRadius + P1P2p.Length)) + return 1d; + + // Calculate the findex of the point to split the ink segment at. + findex = (P1Xp.Length - beginRadius) / (endRadius - beginRadius + P1P2p.Length); + System.Diagnostics.Debug.Assert(!double.IsNaN(findex)); + + // Find the projection of the split point to the line of this segment. + Vector S = spineVector * findex; + + double r = GetProjectionFIndex(hitBegin - S, hitEnd - S); + + // If the nearest point misses the segment, then find the findex + // of the node nearest to the segment. + if (false == DoubleUtil.IsBetweenZeroAndOne(r)) + { + findex = ClipTest(spineVector, beginRadius, endRadius, (0 > r) ? hitBegin : hitEnd); + System.Diagnostics.Debug.Assert(!double.IsNaN(findex)); + } + } + } + return AdjustFIndex(findex); + } + + /// + /// Clip-Testing a circular inking segment again a hitting point. + /// + /// What need to find out a doulbe value s, which is between 0 and 1, such that + /// DistanceOf(hit - s*spine) = beginRadius + s * (endRadius - beginRadius) + /// That is + /// (hit.X-s*spine.X)^2 + (hit.Y-s*spine.Y)^2 = [beginRadius + s*(endRadius-beginRadius)]^2 + /// Rearrange + /// A*s^2 + B*s + C = 0 + /// where the value of A, B and C are described in the source code. + /// Solving for s: + /// s = (-B + sqrt(B^2-4A*C))/(2A) or s = (-B - sqrt(B^2-4A*C))/(2A) + /// The smaller value between 0 and 1 is the one we want and discard the other one. + /// + /// Represent the spine of the inking segment pointing from the beginNode to endNode + /// Radius of the beginNode + /// Radius of the endNode + /// The hitting point + /// A double which represents the location for cutting + private static double ClipTest(Vector spine, double beginRadius, double endRadius, Vector hit) + { + double radDiff = endRadius - beginRadius; + double A = spine.X * spine.X + spine.Y * spine.Y - radDiff * radDiff; + double B = -2.0f * (hit.X * spine.X + hit.Y * spine.Y + beginRadius * radDiff); + double C = hit.X * hit.X + hit.Y * hit.Y - beginRadius * beginRadius; + + // There checks are here since if either fail no real solution can be caculated and we may + // as well bail out now and save the caculations that are below. + if (DoubleUtil.IsZero(A) || !DoubleUtil.GreaterThanOrClose(B * B, 4.0f * A * C)) + return 1d; + + double tmp = Math.Sqrt(B * B - 4.0f * A * C); + double s1 = (-B + tmp) / (2.0f * A); + double s2 = (-B - tmp) / (2.0f * A); + double findex; + + if (DoubleUtil.IsBetweenZeroAndOne(s1) && DoubleUtil.IsBetweenZeroAndOne(s1)) + { + findex = Math.Min(s1, s2); + } + else if (DoubleUtil.IsBetweenZeroAndOne(s1)) + { + findex = s1; + } + else if (DoubleUtil.IsBetweenZeroAndOne(s2)) + { + findex = s2; + } + else + { + // There is still possiblity that value like 1.0000000000000402 is not considered + // as "IsOne" by DoubleUtil class. We should be at either one of the following two cases: + // 1. s1/s2 around 0 but not close enough (say -0.0000000000001) + // 2. s1/s2 around 1 but not close enought (say 1.0000000000000402) + + if (s1 > 1d && s2 > 1d) + { + findex = 1d; + } + else if (s1 < 0d && s2 < 0d) + { + findex = 0d; + } + else + { + findex = Math.Abs(Math.Min(s1, s2) - 0d) < Math.Abs(Math.Max(s1, s2) - 1d) ? 0d : 1d; + } + } + return AdjustFIndex(findex); + } + + /// + /// Helper function to find out the relative location of a segment {segBegin, segEnd} against + /// a strokeNode (spine). + /// + /// the spineVector of the StrokeNode + /// Start position of the line segment + /// End position of the line segment + /// HitResult + private static HitResult WhereIsNodeAboutSegment(Vector spine, Vector segBegin, Vector segEnd) + { + HitResult whereabout = HitResult.Right; + Vector segVector = segEnd - segBegin; + + if ((WhereIsVectorAboutVector(-segBegin, segVector) == HitResult.Left) + && !DoubleUtil.IsZero(Vector.Determinant(spine, segVector))) + { + whereabout = HitResult.Left; + } + return whereabout; + } + + /// + /// Helper method to calculate the exact location to cut + /// + /// Vector the relative location of the two inking nodes + /// the begin point of the hitting segment + /// the end point of the hitting segment + /// endNode radius + /// beginNode radius + /// StrokeFIndices representing the location for cutting + private void CalculateCutLocations( + Vector spineVector, Vector hitBegin, Vector hitEnd, double endRadius, double beginRadius, ref StrokeFIndices result) + { + // Find out whether the {hitBegin, hitEnd} segment intersects with the contour + // of the stroke segment, and find the lower index of the fragment to cut out. + if (!DoubleUtil.AreClose(result.EndFIndex, StrokeFIndices.AfterLast)) + { + if (WhereIsNodeAboutSegment(spineVector, hitBegin, hitEnd) == HitResult.Left) + { + double findex = 1 - ClipTest(spineVector, endRadius, beginRadius, hitBegin, hitEnd); + if (findex > result.EndFIndex) + { + result.EndFIndex = findex; + } + } + } + + // Find out whether the {hitBegin, hitEnd} segment intersects with the contour + // of the stroke segment, and find the higher index of the fragment to cut out. + if (!DoubleUtil.AreClose(result.BeginFIndex, StrokeFIndices.BeforeFirst)) + { + hitBegin -= spineVector; + hitEnd -= spineVector; + if (WhereIsNodeAboutSegment(-spineVector, hitBegin, hitEnd) == HitResult.Left) + { + double findex = ClipTest(-spineVector, beginRadius, endRadius, hitBegin, hitEnd); + if (findex < result.BeginFIndex) + { + result.BeginFIndex = findex; + } + } + } + } + + private double _radius = 0; + private Size _radii; + private Matrix _transform; + private Matrix _nodeShapeToCircle; + private Matrix _circleToNodeShape; + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/ErasingStroke.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/ErasingStroke.cs new file mode 100644 index 0000000..e009503 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/ErasingStroke.cs @@ -0,0 +1,350 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +//#define POINTS_FILTER_TRACE + +using System; +using System.Windows; +using System.Collections.Generic; +using WpfInk.PresentationCore.System.Windows; +using WpfInk.PresentationCore.System.Windows.Ink; + + +namespace MS.Internal.Ink +{ + #region ErasingStroke + + /// + /// This class represents a contour of an erasing stroke, and provides + /// internal API for static and incremental stroke_contour vs stroke_contour + /// hit-testing. + /// + internal class ErasingStroke + { + #region Constructors + + /// + /// Constructor for incremental erasing + /// + /// The shape of the eraser's tip + internal ErasingStroke(StylusShape erasingShape) + { + System.Diagnostics.Debug.Assert(erasingShape != null); + _nodeIterator = new StrokeNodeIterator(erasingShape); + } + + /// + /// Constructor for static (atomic) erasing + /// + /// The shape of the eraser's tip + /// the spine of the erasing stroke + internal ErasingStroke(StylusShape erasingShape, IEnumerable path) + : this(erasingShape) + { + MoveTo(path); + } + + #endregion + + #region API + + /// + /// Generates stroke nodes along a given path. + /// Drops any previously genererated nodes. + /// + /// + internal void MoveTo(IEnumerable path) + { + System.Diagnostics.Debug.Assert((path != null) && (IEnumerablePointHelper.GetCount(path) != 0)); + Point[] points = IEnumerablePointHelper.GetPointArray(path); + + if (_erasingStrokeNodes == null) + { + _erasingStrokeNodes = new List(points.Length); + } + else + { + _erasingStrokeNodes.Clear(); + } + + + _bounds = Rect.Empty; + _nodeIterator = _nodeIterator.GetIteratorForNextSegment(points.Length > 1 ? FilterPoints(points) : points); + for (int i = 0; i < _nodeIterator.Count; i++) + { + StrokeNode strokeNode = _nodeIterator[i]; + _bounds.Union(strokeNode.GetBoundsConnected()); + _erasingStrokeNodes.Add(strokeNode); + } +#if POINTS_FILTER_TRACE + _totalPointsAdded += path.Length; + System.Diagnostics.Debug.WriteLine(String.Format("Total Points added: {0} screened: {1} collinear screened: {2}", _totalPointsAdded, _totalPointsScreened, _collinearPointsScreened)); +#endif + + } + + /// + /// Returns the bounds of the eraser's last move. + /// + /// + internal Rect Bounds { get { return _bounds; } } + + /// + /// Hit-testing for stroke erase scenario. + /// + /// the stroke nodes to iterate + /// true if the strokes intersect, false otherwise + internal bool HitTest(StrokeNodeIterator iterator) + { + System.Diagnostics.Debug.Assert(iterator != null); + + if ((_erasingStrokeNodes == null) || (_erasingStrokeNodes.Count == 0)) + { + return false; + } + + Rect inkSegmentBounds = Rect.Empty; + for (int i = 0; i < iterator.Count; i++) + { + StrokeNode inkStrokeNode = iterator[i]; + Rect inkNodeBounds = inkStrokeNode.GetBounds(); + inkSegmentBounds.Union(inkNodeBounds); + + if (inkSegmentBounds.IntersectsWith(_bounds)) + { + // can be optimized (using pre-computed bounds + // of parts of the erasing stroke) + foreach (StrokeNode erasingStrokeNode in _erasingStrokeNodes) + { + if (inkSegmentBounds.IntersectsWith(erasingStrokeNode.GetBoundsConnected()) + && erasingStrokeNode.HitTest(inkStrokeNode)) + { + return true; + } + } + } + } + return false; + } + + /// + /// Hit-testing for point erase. + /// + /// + /// + /// + internal bool EraseTest(StrokeNodeIterator iterator, List intersections) + { + System.Diagnostics.Debug.Assert(iterator != null); + System.Diagnostics.Debug.Assert(intersections != null); + intersections.Clear(); + + List eraseAt = new List(); + + if ((_erasingStrokeNodes == null) || (_erasingStrokeNodes.Count == 0)) + { + return false; + } + + Rect inkSegmentBounds = Rect.Empty; + for (int x = 0; x < iterator.Count; x++) + { + StrokeNode inkStrokeNode = iterator[x]; + Rect inkNodeBounds = inkStrokeNode.GetBounds(); + inkSegmentBounds.Union(inkNodeBounds); + + if (inkSegmentBounds.IntersectsWith(_bounds)) + { + // can be optimized (using pre-computed bounds + // of parts of the erasing stroke) + int index = eraseAt.Count; + foreach (StrokeNode erasingStrokeNode in _erasingStrokeNodes) + { + if (false == inkSegmentBounds.IntersectsWith(erasingStrokeNode.GetBoundsConnected())) + { + continue; + } + + StrokeFIndices fragment = inkStrokeNode.CutTest(erasingStrokeNode); + if (fragment.IsEmpty) + { + continue; + } + + // Merge it with the other results for this ink segment + bool inserted = false; + for (int i = index; i < eraseAt.Count; i++) + { + StrokeFIndices lastFragment = eraseAt[i]; + if (fragment.BeginFIndex < lastFragment.EndFIndex) + { + // If the fragments overlap, merge them + if (fragment.EndFIndex > lastFragment.BeginFIndex) + { + fragment = new StrokeFIndices( + Math.Min(lastFragment.BeginFIndex, fragment.BeginFIndex), + Math.Max(lastFragment.EndFIndex, fragment.EndFIndex)); + + // If the fragment doesn't go beyond lastFragment, break + if ((fragment.EndFIndex <= lastFragment.EndFIndex) || ((i + 1) == eraseAt.Count)) + { + inserted = true; + eraseAt[i] = fragment; + break; + } + else + { + eraseAt.RemoveAt(i); + i--; + } + } + // insert otherwise + else + { + eraseAt.Insert(i, fragment); + inserted = true; + break; + } + } + } + + // If not merged nor inserted, add it to the end of the list + if (false == inserted) + { + eraseAt.Add(fragment); + } + // Break out if the entire ink segment is hit - {BeforeFirst, AfterLast} + if (eraseAt[eraseAt.Count - 1].IsFull) + { + break; + } + } + // Merge inter-segment overlapping fragments + if ((index > 0) && (index < eraseAt.Count)) + { + StrokeFIndices lastFragment = eraseAt[index - 1]; + if (DoubleUtil.AreClose(lastFragment.EndFIndex, StrokeFIndices.AfterLast)) + { + if (DoubleUtil.AreClose(eraseAt[index].BeginFIndex, StrokeFIndices.BeforeFirst)) + { + lastFragment.EndFIndex = eraseAt[index].EndFIndex; + eraseAt[index - 1] = lastFragment; + eraseAt.RemoveAt(index); + } + else + { + lastFragment.EndFIndex = inkStrokeNode.Index; + eraseAt[index - 1] = lastFragment; + } + } + } + } + // Start next ink segment + inkSegmentBounds = inkNodeBounds; + } + if (eraseAt.Count != 0) + { + foreach (StrokeFIndices segment in eraseAt) + { + intersections.Add(new StrokeIntersection(segment.BeginFIndex, StrokeFIndices.AfterLast, + StrokeFIndices.BeforeFirst, segment.EndFIndex)); + } + } + return (eraseAt.Count != 0); + } + + #endregion + + #region private API + private Point[] FilterPoints(Point[] path) + { + System.Diagnostics.Debug.Assert(path.Length > 1); + Point back2, back1; + int i; + List newPath = new List(); + if (_nodeIterator.Count == 0) + { + newPath.Add(path[0]); + newPath.Add(path[1]); + back2 = path[0]; + back1 = path[1]; + i = 2; + } + else + { + newPath.Add(path[0]); + back2 = _nodeIterator[_nodeIterator.Count - 1].Position; + back1 = path[0]; + i = 1; + } + + while (i < path.Length) + { + if (DoubleUtil.AreClose(back1, path[i])) + { + // Filter out duplicate points + i++; + continue; + } + + Vector begin = back2 - back1; + Vector end = path[i] - back1; + //On a line defined by begin & end, finds the findex of the point nearest to the origin (0,0). + double findex = StrokeNodeOperations.GetProjectionFIndex(begin, end); + + if (DoubleUtil.IsBetweenZeroAndOne(findex)) + { + Vector v = (begin + (end - begin) * findex); + if (v.LengthSquared < CollinearTolerance) + { + // The point back1 can be considered as on the line from back2 to the toTest StrokeNode. + // Modify the previous point. + newPath[newPath.Count - 1] = path[i]; + back1 = path[i]; + i++; +#if POINTS_FILTER_TRACE + _collinearPointsScreened ++; +#endif + continue; + } + } + + // Add the surviving point into the list. + newPath.Add(path[i]); + back2 = back1; + back1 = path[i]; + i++; + } +#if POINTS_FILTER_TRACE + _totalPointsScreened += path.Length - newPath.Count; +#endif + return newPath.ToArray(); + } + + #endregion + + #region Fields + + private StrokeNodeIterator _nodeIterator; + private List _erasingStrokeNodes = null; + private Rect _bounds = Rect.Empty; + +#if POINTS_FILTER_TRACE + private int _totalPointsAdded = 0; + private int _totalPointsScreened = 0; + private int _collinearPointsScreened = 0; +#endif + + // The collinear tolerance used in points filtering algorithm. The valie + // should be further tuned considering trade-off of performance and accuracy. + // In general, the larger the value, more points are filtered but less accurate. + // For a value of 0.5, typically 70% - 80% percent of the points are filtered out. + private static readonly double CollinearTolerance = 0.1f; + + #endregion + } + + #endregion +} + diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/ExtendedProperty.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/ExtendedProperty.cs new file mode 100644 index 0000000..ef85c93 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/ExtendedProperty.cs @@ -0,0 +1,214 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +using System; +using SRID = MS.Internal.PresentationCore.SRID; + +namespace WpfInk.PresentationCore.System.Windows.Ink +{ + /// + /// Drawing Attribute Key/Value pair for specifying each attribute + /// + internal sealed class ExtendedProperty + { + /// + /// Create a new drawing attribute with the specified key and value + /// + /// Identifier of attribute + /// Attribute value - not that the Type for value is tied to the id + /// Value type must be compatible with attribute Id + internal ExtendedProperty(Guid id, object value) + { + if (id == Guid.Empty) + { + throw new ArgumentException(SR.Get(SRID.InvalidGuid)); + } + _id = id; + Value = value; + } + + /// Returns a value that can be used to store and lookup + /// ExtendedProperty objects in a hash table + public override int GetHashCode() + { + return Id.GetHashCode() ^ Value.GetHashCode(); + } + + /// Determine if two ExtendedProperty objects are equal + public override bool Equals(object obj) + { + if (obj == null || obj.GetType() != GetType()) + { + return false; + } + + ExtendedProperty that = (ExtendedProperty) obj; + + if (that.Id == this.Id) + { + Type type1 = this.Value.GetType(); + Type type2 = that.Value.GetType(); + + if (type1.IsArray && type2.IsArray) + { + Type elementType1 = type1.GetElementType(); + Type elementType2 = type2.GetElementType(); + if (elementType1 == elementType2 && + elementType1.IsValueType && + type1.GetArrayRank() == 1 && + elementType2.IsValueType && + type2.GetArrayRank() == 1) + { + Array array1 = (Array) this.Value; + Array array2 = (Array) that.Value; + if (array1.Length == array2.Length) + { + for (int i = 0; i < array1.Length; i++) + { + if (!array1.GetValue(i).Equals(array2.GetValue(i))) + { + return false; + } + } + return true; + } + } + } + else + { + return that.Value.Equals(this.Value); + } + } + return false; + } + + /// Overload of the equality operator for comparing + /// two ExtendedProperty objects + public static bool operator ==(ExtendedProperty first, ExtendedProperty second) + { + if ((object) first == null && (object) second == null) + { + return true; + } + else if ((object) first == null || (object) second == null) + { + return false; + } + else + { + return first.Equals(second); + } + } + + /// Compare two custom attributes for Id and value inequality + /// Value comparison is performed based on Value.Equals + public static bool operator !=(ExtendedProperty first, ExtendedProperty second) + { + return !(first == second); + } + + /// + /// Returns a debugger-friendly version of the ExtendedProperty + /// + /// + public override string ToString() + { + string val; + if (Value == null) + { + val = ""; + } + else if (Value is string) + { + val = "\"" + Value.ToString() + "\""; + } + else + { + val = Value.ToString(); + } + return Id + "," + val; + } + + /// + /// Retrieve the Identifier, or key, for Drawing Attribute key/value pair + /// + internal Guid Id + { + get + { + return _id; + } + } + /// + /// Set or retrieve the value for ExtendedProperty key/value pair + /// + /// Value type must be compatible with attribute Id + /// Value can be null. + internal object Value + { + get + { + return _value; + } + set + { + if (value == null) + { + throw new ArgumentNullException("value"); + } + + _value = value; + } + } + + ///// + ///// Creates a copy of the Guid and Value + ///// + ///// + //internal ExtendedProperty Clone() + //{ + // // + // // the only properties we accept are value types or arrays of + // // value types with the exception of string. + // // + // Guid guid = _id; //Guid is a ValueType that copies on assignment + // Type type = _value.GetType(); + + // // + // // check for the very common, copy on assignment + // // types (ValueType or string) + // // + // if (type.IsValueType || type == typeof(string)) + // { + // // + // // either ValueType or string is passed by value + // // + // return new ExtendedProperty(guid, _value); + // } + // else if (type.IsArray) + // { + // Type elementType = type.GetElementType(); + // if (elementType.IsValueType && type.GetArrayRank() == 1) + // { + // // + // // copy the array memebers, which we know are copy + // // on assignment value types + // // + // Array newArray = Array.CreateInstance(elementType, ((Array) _value).Length); + // Array.Copy((Array) _value, newArray, ((Array) _value).Length); + // return new ExtendedProperty(guid, newArray); + // } + // } + // // + // // we didn't find a type we expect, throw + // // + // throw new InvalidOperationException(SR.Get(SRID.InvalidDataTypeForExtendedProperty)); + //} + + + private Guid _id; // id of attribute + private object _value; // data in attribute + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/ExtendedPropertyCollection.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/ExtendedPropertyCollection.cs new file mode 100644 index 0000000..fbec81c --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/ExtendedPropertyCollection.cs @@ -0,0 +1,372 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; + +using SRID = MS.Internal.PresentationCore.SRID; + +namespace WpfInk.PresentationCore.System.Windows.Ink +{ + /// + /// A collection of name/value pairs, called ExtendedProperties, can be stored + /// in a collection to enable aggregate operations and assignment to Ink object + /// model objects, such StrokeCollection and Stroke. + /// + internal sealed class ExtendedPropertyCollection //does not implement ICollection, we don't need it + { + /// + /// Create a new empty ExtendedPropertyCollection + /// + internal ExtendedPropertyCollection() + { + } + + /// Overload of the Equals method which determines if two ExtendedPropertyCollection + /// objects contain equivalent key/value pairs + public override bool Equals(object o) + { + if (o == null || o.GetType() != GetType()) + { + return false; + } + + // + // compare counts + // + ExtendedPropertyCollection that = (ExtendedPropertyCollection) o; + if (that.Count != this.Count) + { + return false; + } + + // + // counts are equal, compare individual items + // + // + for (int x = 0; x < that.Count; x++) + { + bool cont = false; + for (int i = 0; i < _extendedProperties.Count; i++) + { + if (_extendedProperties[i].Equals(that[x])) + { + cont = true; + break; + } + } + if (!cont) + { + return false; + } + } + return true; + } + + /// Overload of the equality operator which determines + /// if two ExtendedPropertyCollections are equal + public static bool operator ==(ExtendedPropertyCollection first, ExtendedPropertyCollection second) + { + // compare the GC ptrs for the obvious reference equality + if (((object) first == null && (object) second == null) || + ((object) first == (object) second)) + { + return true; + } + // otherwise, if one of the ptrs are null, but not the other then return false + else if ((object) first == null || (object) second == null) + { + return false; + } + // finally use the full `blown value-style comparison against the collection contents + else + { + return first.Equals(second); + } + } + + /// Overload of the not equals operator to determine if two + /// ExtendedPropertyCollections have different key/value pairs + public static bool operator !=(ExtendedPropertyCollection first, ExtendedPropertyCollection second) + { + return !(first == second); + } + + /// + /// GetHashCode + /// + public override int GetHashCode() + { + return base.GetHashCode(); + } + + /// + /// Check to see if the attribute is defined in the collection. + /// + /// Attribute identifier + /// True if attribute is set in the mask, false otherwise + internal bool Contains(Guid attributeId) + { + for (int x = 0; x < _extendedProperties.Count; x++) + { + if (_extendedProperties[x].Id == attributeId) + { + // + // a typical pattern is to first check if + // ep.Contains(guid) + // before accessing: + // object o = ep[guid]; + // + // I'm caching the index that contains returns so that we + // can look there first for the guid in the indexer + // + _optimisticIndex = x; + return true; + } + } + return false; + } + + ///// + ///// Copies the ExtendedPropertyCollection + ///// + ///// Copy of the ExtendedPropertyCollection + ///// Any reference types held in the collection will only be deep copied (e.g. Arrays). + ///// + //internal ExtendedPropertyCollection Clone() + //{ + // ExtendedPropertyCollection copied = new ExtendedPropertyCollection(); + // for (int x = 0; x < _extendedProperties.Count; x++) + // { + // copied.Add(_extendedProperties[x].Clone()); + // } + // return copied; + //} + + /// + /// Add + /// + /// Id + /// value + internal void Add(Guid id, object value) + { + if (this.Contains(id)) + { + throw new ArgumentException(SR.Get(SRID.EPExists), "id"); + } + + ExtendedProperty extendedProperty = new ExtendedProperty(id, value); + + //this will raise change events + this.Add(extendedProperty); + } + + + /// + /// Remove + /// + /// id + internal void Remove(Guid id) + { + if (!Contains(id)) + { + throw new ArgumentException(SR.Get(SRID.EPGuidNotFound), "id"); + } + + ExtendedProperty propertyToRemove = GetExtendedPropertyById(id); + Debug.Assert(propertyToRemove != null); + + _extendedProperties.Remove(propertyToRemove); + + // + // this value is bogus now + // + _optimisticIndex = -1; + + // fire notification event + if (this.Changed != null) + { + ExtendedPropertiesChangedEventArgs eventArgs + = new ExtendedPropertiesChangedEventArgs(propertyToRemove, null); + this.Changed(this, eventArgs); + } + } + + /// + /// Retrieve the Guid array of ExtendedProperty Ids in the collection. + /// Guid[] is of type . + /// + /// + internal Guid[] GetGuidArray() + { + if (_extendedProperties.Count > 0) + { + Guid[] guids = new Guid[_extendedProperties.Count]; + for (int i = 0; i < _extendedProperties.Count; i++) + { + guids[i] = this[i].Id; + } + return guids; + } + else + { + return Array.Empty(); + } + } + + /// + /// Generic accessor for the ExtendedPropertyCollection. + /// + /// Attribue Id to find + /// Value for attribute specified by Id + /// Specified identifier was not found + /// + /// Note that you can access extended properties via this indexer. + /// + internal object this[Guid attributeId] + { + get + { + ExtendedProperty ep = GetExtendedPropertyById(attributeId); + if (ep == null) + { + throw new ArgumentException(SR.Get(SRID.EPNotFound), "attributeId"); + } + return ep.Value; + } + set + { + if (value == null) + { + throw new ArgumentNullException("value"); + } + for (int i = 0; i < _extendedProperties.Count; i++) + { + ExtendedProperty currentProperty = _extendedProperties[i]; + + if (currentProperty.Id == attributeId) + { + object oldValue = currentProperty.Value; + //this will raise events + currentProperty.Value = value; + + //raise change if anyone is listening + if (this.Changed != null) + { + ExtendedPropertiesChangedEventArgs eventArgs + = new ExtendedPropertiesChangedEventArgs( + new ExtendedProperty(currentProperty.Id, oldValue), //old prop + currentProperty); //new prop + + this.Changed(this, eventArgs); + } + return; + } + } + + // + // we didn't find the Id in the collection, we need to add it. + // this will raise change notifications + // + ExtendedProperty attributeToAdd = new ExtendedProperty(attributeId, value); + this.Add(attributeToAdd); + } + } + + /// + /// Generic accessor for the ExtendedPropertyCollection. + /// + /// index into masking collection to retrieve + /// ExtendedProperty specified at index + /// Index was not found + /// + /// Note that you can access extended properties via this indexer. + /// + internal ExtendedProperty this[int index] + { + get + { + return _extendedProperties[index]; + } + } + + /// + /// Retrieve the number of ExtendedProperty objects in the collection. + /// Count is of type . + /// + /// + internal int Count + { + get + { + return _extendedProperties.Count; + } + } + + /// + /// Event fired whenever a ExtendedProperty is modified in the collection + /// + internal event ExtendedPropertiesChangedEventHandler Changed; + + + /// + /// private Add, we need to consider making this public in order to implement the generic ICollection + /// + private void Add(ExtendedProperty extendedProperty) + { + Debug.Assert(!this.Contains(extendedProperty.Id), "ExtendedProperty already belongs to the collection"); + + _extendedProperties.Add(extendedProperty); + + // fire notification event + if (this.Changed != null) + { + ExtendedPropertiesChangedEventArgs eventArgs + = new ExtendedPropertiesChangedEventArgs(null, extendedProperty); + this.Changed(this, eventArgs); + } + } + + /// + /// Private helper for getting an EP out of our internal collection + /// + /// id + private ExtendedProperty GetExtendedPropertyById(Guid id) + { + // + // a typical pattern is to first check if + // ep.Contains(guid) + // before accessing: + // object o = ep[guid]; + // + // The last call to .Contains sets this index + // + if (_optimisticIndex != -1 && + _optimisticIndex < _extendedProperties.Count && + _extendedProperties[_optimisticIndex].Id == id) + { + return _extendedProperties[_optimisticIndex]; + } + + //we didn't find the ep optimistically, perform linear lookup + for (int i = 0; i < _extendedProperties.Count; i++) + { + if (_extendedProperties[i].Id == id) + { + return _extendedProperties[i]; + } + } + + return null; + } + + // the set of ExtendedProperties stored in this collection + private List _extendedProperties = new List(); + + + //used to optimize across Contains / Index calls + private int _optimisticIndex = -1; + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/InkSerializedFormat/ISFTagAndGuidCache.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/InkSerializedFormat/ISFTagAndGuidCache.cs new file mode 100644 index 0000000..71b25b8 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/InkSerializedFormat/ISFTagAndGuidCache.cs @@ -0,0 +1,154 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections; +using System.IO; + +namespace MS.Internal.Ink.InkSerializedFormat +{ + /// + /// [To be supplied.] + /// + internal static class KnownIdCache + { + // This id table includes the original Guids that were hardcoded + // into ISF for the TabletPC v1 release + public static Guid[] OriginalISFIdTable = { + new Guid(0x598a6a8f, 0x52c0, 0x4ba0, 0x93, 0xaf, 0xaf, 0x35, 0x74, 0x11, 0xa5, 0x61), + new Guid(0xb53f9f75, 0x04e0, 0x4498, 0xa7, 0xee, 0xc3, 0x0d, 0xbb, 0x5a, 0x90, 0x11), + new Guid(0x735adb30, 0x0ebb, 0x4788, 0xa0, 0xe4, 0x0f, 0x31, 0x64, 0x90, 0x05, 0x5d), + new Guid(0x6e0e07bf, 0xafe7, 0x4cf7, 0x87, 0xd1, 0xaf, 0x64, 0x46, 0x20, 0x84, 0x18), + new Guid(0x436510c5, 0xfed3, 0x45d1, 0x8b, 0x76, 0x71, 0xd3, 0xea, 0x7a, 0x82, 0x9d), + new Guid(0x78a81b56, 0x0935, 0x4493, 0xba, 0xae, 0x00, 0x54, 0x1a, 0x8a, 0x16, 0xc4), + new Guid(0x7307502d, 0xf9f4, 0x4e18, 0xb3, 0xf2, 0x2c, 0xe1, 0xb1, 0xa3, 0x61, 0x0c), + new Guid(0x6da4488b, 0x5244, 0x41ec, 0x90, 0x5b, 0x32, 0xd8, 0x9a, 0xb8, 0x08, 0x09), + new Guid(0x8b7fefc4, 0x96aa, 0x4bfe, 0xac, 0x26, 0x8a, 0x5f, 0x0b, 0xe0, 0x7b, 0xf5), + new Guid(0xa8d07b3a, 0x8bf0, 0x40b0, 0x95, 0xa9, 0xb8, 0x0a, 0x6b, 0xb7, 0x87, 0xbf), + new Guid(0x0e932389, 0x1d77, 0x43af, 0xac, 0x00, 0x5b, 0x95, 0x0d, 0x6d, 0x4b, 0x2d), + new Guid(0x029123b4, 0x8828, 0x410b, 0xb2, 0x50, 0xa0, 0x53, 0x65, 0x95, 0xe5, 0xdc), + new Guid(0x82dec5c7, 0xf6ba, 0x4906, 0x89, 0x4f, 0x66, 0xd6, 0x8d, 0xfc, 0x45, 0x6c), + new Guid(0x0d324960, 0x13b2, 0x41e4, 0xac, 0xe6, 0x7a, 0xe9, 0xd4, 0x3d, 0x2d, 0x3b), + new Guid(0x7f7e57b7, 0xbe37, 0x4be1, 0xa3, 0x56, 0x7a, 0x84, 0x16, 0x0e, 0x18, 0x93), + new Guid(0x5d5d5e56, 0x6ba9, 0x4c5b, 0x9f, 0xb0, 0x85, 0x1c, 0x91, 0x71, 0x4e, 0x56), + new Guid(0x6a849980, 0x7c3a, 0x45b7, 0xaa, 0x82, 0x90, 0xa2, 0x62, 0x95, 0x0e, 0x89), + new Guid(0x33c1df83, 0xecdb, 0x44f0, 0xb9, 0x23, 0xdb, 0xd1, 0xa5, 0xb2, 0x13, 0x6e), + new Guid(0x5329cda5, 0xfa5b, 0x4ed2, 0xbb, 0x32, 0x83, 0x46, 0x01, 0x72, 0x44, 0x28), + new Guid(0x002df9af, 0xdd8c, 0x4949, 0xba, 0x46, 0xd6, 0x5e, 0x10, 0x7d, 0x1a, 0x8a), + new Guid(0x9d32b7ca, 0x1213, 0x4f54, 0xb7, 0xe4, 0xc9, 0x05, 0x0e, 0xe1, 0x7a, 0x38), + new Guid(0xe71caab9, 0x8059, 0x4c0d, 0xa2, 0xdb, 0x7c, 0x79, 0x54, 0x47, 0x8d, 0x82), + new Guid(0x5c0b730a, 0xf394, 0x4961, 0xa9, 0x33, 0x37, 0xc4, 0x34, 0xf4, 0xb7, 0xeb), + new Guid(0x2812210f, 0x871e, 0x4d91, 0x86, 0x07, 0x49, 0x32, 0x7d, 0xdf, 0x0a, 0x9f), + new Guid(0x8359a0fa, 0x2f44, 0x4de6, 0x92, 0x81, 0xce, 0x5a, 0x89, 0x9c, 0xf5, 0x8f), + new Guid(0x4c4642dd, 0x479e, 0x4c66, 0xb4, 0x40, 0x1f, 0xcd, 0x83, 0x95, 0x8f, 0x00), + new Guid(0xce2d9a8a, 0xe58e, 0x40ba, 0x93, 0xfa, 0x18, 0x9b, 0xb3, 0x90, 0x00, 0xae), + new Guid(0xc3c7480f, 0x5839, 0x46ef, 0xa5, 0x66, 0xd8, 0x48, 0x1c, 0x7a, 0xfe, 0xc1), + new Guid(0xea2278af, 0xc59d, 0x4ef4, 0x98, 0x5b, 0xd4, 0xbe, 0x12, 0xdf, 0x22, 0x34), + new Guid(0xb8630dc9, 0xcc5c, 0x4c33, 0x8d, 0xad, 0xb4, 0x7f, 0x62, 0x2b, 0x8c, 0x79), + new Guid(0x15e2f8e6, 0x6381, 0x4e8b, 0xa9, 0x65, 0x01, 0x1f, 0x7d, 0x7f, 0xca, 0x38), + new Guid(0x7066fbe4, 0x473e, 0x4675, 0x9c, 0x25, 0x00, 0x26, 0x82, 0x9b, 0x40, 0x1f), + new Guid(0xbbc85b9a, 0xade6, 0x4093, 0xb3, 0xbb, 0x64, 0x1f, 0xa1, 0xd3, 0x7a, 0x1a), + new Guid(0x39143d3, 0x78cb, 0x449c, 0xa8, 0xe7, 0x67, 0xd1, 0x88, 0x64, 0xc3, 0x32), + new Guid(0x67743782, 0xee5, 0x419a, 0xa1, 0x2b, 0x27, 0x3a, 0x9e, 0xc0, 0x8f, 0x3d), + new Guid(0xf0720328, 0x663b, 0x418f, 0x85, 0xa6, 0x95, 0x31, 0xae, 0x3e, 0xcd, 0xfa), + new Guid(0xa1718cdd, 0xdac, 0x4095, 0xa1, 0x81, 0x7b, 0x59, 0xcb, 0x10, 0x6b, 0xfb), + new Guid(0x810a74d2, 0x6ee2, 0x4e39, 0x82, 0x5e, 0x6d, 0xef, 0x82, 0x6a, 0xff, 0xc5), + }; + + // Size of data used by identified by specified Guid/Id + + public enum OriginalISFIdIndex : uint + { + X = 0, + Y = 1, + Z = 2, + PacketStatus = 3, + TimerTick = 4, + SerialNumber = 5, + NormalPressure = 6, + TangentPressure = 7, + ButtonPressure = 8, + XTiltOrientation = 9, + YTiltOrientation = 10, + AzimuthOrientation = 11, + AltitudeOrientation = 12, + TwistOrientation = 13, + PitchRotation = 14, + RollRotation = 15, + YawRotation = 16, + PenStyle = 17, + ColorRef = 18, + StylusWidth = 19, + StylusHeight = 20, + PenTip = 21, + DrawingFlags = 22, + CursorId = 23, + WordAlternates = 24, + CharAlternates = 25, + InkMetrics = 26, + GuideStructure = 27, + Timestamp = 28, + Language = 29, + Transparency = 30, + CurveFittingError = 31, + RecoLattice = 32, + CursorDown = 33, + SecondaryTipSwitch = 34, + BarrelDown = 35, + TabletPick = 36, + RasterOperation = 37, + MAXIMUM = 37, + } + + // This id table includes the Guids that used the internal persistence APIs + // - meaning they didn't have the data type information encoded in ISF + public static Guid[] TabletInternalIdTable = { + // Highlighter + new Guid(0x9b6267b8, 0x3968, 0x4048, 0xab, 0x74, 0xf4, 0x90, 0x40, 0x6a, 0x2d, 0xfa), + // Ink properties + new Guid(0x7fc30e91, 0xd68d, 0x4f07, 0x8b, 0x62, 0x6, 0xf6, 0xd2, 0x73, 0x1b, 0xed), + // Ink Style Bold + new Guid(0xe02fb5c1, 0x9693, 0x4312, 0xa4, 0x34, 0x0, 0xde, 0x7f, 0x3a, 0xd4, 0x93), + // Ink Style Italics + new Guid(0x5253b51, 0x49c6, 0x4a04, 0x89, 0x93, 0x64, 0xdd, 0x9a, 0xbd, 0x84, 0x2a), + // Stroke Timestamp + new Guid(0x4ea66c4, 0xf33a, 0x461b, 0xb8, 0xfe, 0x68, 0x7, 0xd, 0x9c, 0x75, 0x75), + // Stroke Time Id + new Guid(0x50b6bc8, 0x3b7d, 0x4816, 0x8c, 0x61, 0xbc, 0x7e, 0x90, 0x5b, 0x21, 0x32), + // Stroke Lattice + new Guid(0x82871c85, 0xe247, 0x4d8c, 0x8d, 0x71, 0x22, 0xe5, 0xd6, 0xf2, 0x57, 0x76), + // Ink Custom Strokes + new Guid(0x33cdbbb3, 0x588f, 0x4e94, 0xb1, 0xfe, 0x5d, 0x79, 0xff, 0xe7, 0x6e, 0x76), + }; + // lookup indices for table of GUIDs used with non-Automation APIs + internal enum TabletInternalIdIndex + { + Highlighter = 0, + InkProperties = 1, + InkStyleBold = 2, + InkStyleItalics = 3, + StrokeTimestamp = 4, + StrokeTimeId = 5, + InkStrokeLattice = 6, + InkCustomStrokes = 7, + MAXIMUM = 7 + } + + // The maximum value that can be encoded into a single byte is 127. + // To improve the chances of storing all of the guids in the ISF guid table + // with single-byte lookups, the guids are broken into two ranges + // 0-50 known tags + // 50-100 known guids (reserved) + // 101-127 custom guids (user-defined guids) + // 128-... more custom guids, but requiring multiples bytes for guid table lookup + + // These values aren't currently used, so comment them out + // static internal uint KnownGuidIndexLimit = MaximumPossibleKnownGuidIndex; + static internal uint MaximumPossibleKnownGuidIndex = 100; + static internal uint CustomGuidBaseIndex = MaximumPossibleKnownGuidIndex; + + // This id table includes the Guids that have been added to ISF as ExtendedProperties + // Note that they are visible to 3rd party applications + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/InkSerializedFormat/InkSerializer.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/InkSerializedFormat/InkSerializer.cs new file mode 100644 index 0000000..17f48b5 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/InkSerializedFormat/InkSerializer.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace MS.Internal.Ink.InkSerializedFormat +{ + internal class StrokeCollectionSerializer + { + internal static readonly double AvalonToHimetricMultiplier = 2540.0d / 96.0d; + internal static readonly double HimetricToAvalonMultiplier = 96.0d / 2540.0d; + } +} + diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/Lasso.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/Lasso.cs new file mode 100644 index 0000000..14ccde6 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/Lasso.cs @@ -0,0 +1,908 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +using System; +using System.Windows; +using System.Collections.Generic; +using System.Globalization; +using WpfInk.PresentationCore.System.Windows; +using WpfInk.PresentationCore.System.Windows.Ink; + +namespace MS.Internal.Ink +{ + #region Lasso + + /// + /// Represents a lasso for selecting/cutting ink strokes with. + /// Lasso is a sequence of points defining a complex region (polygon) + /// + internal class Lasso + { + #region Constructors + + /// + /// Default c-tor. Used in incremental hit-testing. + /// + internal Lasso() + { + _points = new List(); + } + + #endregion + + #region API + + /// + /// Returns the bounds of the lasso + /// + internal Rect Bounds + { + get { return _bounds; } + set { _bounds = value; } + } + + /// + /// Tells whether the lasso captures any area + /// + internal bool IsEmpty + { + get + { + System.Diagnostics.Debug.Assert(_points != null); + // The value is based on the assumption that the lasso is normalized + // i.e. it has no duplicate points or collinear sibling segments. + return (_points.Count < 3); + } + } + + /// + /// Returns the count of points in the lasso + /// + internal int PointCount + { + get + { + System.Diagnostics.Debug.Assert(_points != null); + return _points.Count; + } + } + + /// + /// Index-based read-only accessor to lasso points + /// + /// index of the point to return + /// a point in the lasso + internal Point this[int index] + { + get + { + System.Diagnostics.Debug.Assert(_points != null); + System.Diagnostics.Debug.Assert((0 <= index) && (index < _points.Count)); + + return _points[index]; + } + } + + /// + /// Extends the lasso by appending more points + /// + /// new points + internal void AddPoints(IEnumerable points) + { + System.Diagnostics.Debug.Assert(null != points); + + foreach (Point point in points) + { + AddPoint(point); + } + } + + /// + /// Appends a point to the lasso + /// + /// new lasso point + internal void AddPoint(Point point) + { + System.Diagnostics.Debug.Assert(_points != null); + if (!Filter(point)) + { + // The point is not filtered, add it to the lasso + AddPointImpl(point); + } + } + + /// + /// This method implement the core algorithm to check whether a point is within a polygon + /// that are formed by the lasso points. + /// + /// + /// true if the point is contained within the lasso; false otherwise + internal bool Contains(Point point) + { + System.Diagnostics.Debug.Assert(_points != null); + + if (false == _bounds.Contains(point)) + { + return false; + } + + bool isHigher = false; + int last = _points.Count; + while (--last >= 0) + { + if (!DoubleUtil.AreClose(_points[last].Y, point.Y)) + { + isHigher = (point.Y < _points[last].Y); + break; + } + } + + bool isInside = false; + Point prevLassoPoint = _points[_points.Count - 1]; + for (int i = 0; i < _points.Count; i++) + { + Point lassoPoint = _points[i]; + if (DoubleUtil.AreClose(lassoPoint.Y, point.Y)) + { + if (DoubleUtil.AreClose(lassoPoint.X, point.X)) + { + isInside = true; + break; + } + if ((0 != i) && DoubleUtil.AreClose(prevLassoPoint.Y, point.Y) && + DoubleUtil.GreaterThanOrClose(point.X, Math.Min(prevLassoPoint.X, lassoPoint.X)) && + DoubleUtil.LessThanOrClose(point.X, Math.Max(prevLassoPoint.X, lassoPoint.X))) + { + isInside = true; + break; + } + } + else if (isHigher != (point.Y < lassoPoint.Y)) + { + isHigher = !isHigher; + if (DoubleUtil.GreaterThanOrClose(point.X, Math.Max(prevLassoPoint.X, lassoPoint.X))) + { + // there certainly is an intersection on the left + isInside = !isInside; + } + else if (DoubleUtil.GreaterThanOrClose(point.X, Math.Min(prevLassoPoint.X, lassoPoint.X))) + { + // The X of the point lies within the x ranges for the segment. + // Calculate the x value of the point where the segment intersects with the line. + Vector lassoSegment = lassoPoint - prevLassoPoint; + System.Diagnostics.Debug.Assert(lassoSegment.Y != 0); + double x = prevLassoPoint.X + (lassoSegment.X / lassoSegment.Y) * (point.Y - prevLassoPoint.Y); + if (DoubleUtil.GreaterThanOrClose(point.X, x)) + { + isInside = !isInside; + } + } + } + prevLassoPoint = lassoPoint; + } + return isInside; + } + + internal StrokeIntersection[] HitTest(StrokeNodeIterator iterator) + { + System.Diagnostics.Debug.Assert(_points != null); + System.Diagnostics.Debug.Assert(iterator != null); + + if (_points.Count < 3) + { + // + // it takes at least 3 points to create a lasso + // + return Array.Empty(); + } + + // + // We're about to perform hit testing with a lasso. + // To do so we need to iterate through each StrokeNode. + // As we do, we calculate the bounding rect between it + // and the previous StrokeNode and store this in 'currentStrokeSegmentBounds' + // + // Next, we check to see if that StrokeNode pair's bounding box intersects + // with the bounding box of the Lasso points. If not, we continue iterating through + // StrokeNode pairs. + // + // If it does, we do a more granular hit test by pairing points in the Lasso, getting + // their bounding box and seeing if that bounding box intersects our current StrokeNode + // pair + // + + Point lastNodePosition = new Point(); + Point lassoLastPoint = _points[_points.Count - 1]; + Rect currentStrokeSegmentBounds = Rect.Empty; + + // Initilize the current crossing to be an empty one + LassoCrossing currentCrossing = LassoCrossing.EmptyCrossing; + + // Creat a list to hold all the crossings + List crossingList = new List(); + for (int i = 0; i < iterator.Count; i++) + { + StrokeNode strokeNode = iterator[i]; + Rect nodeBounds = strokeNode.GetBounds(); + currentStrokeSegmentBounds.Union(nodeBounds); + + // Skip the node if it's outside of the lasso's bounds + if (currentStrokeSegmentBounds.IntersectsWith(_bounds) == true) + { + // currentStrokeSegmentBounds, made up of the bounding box of + // this StrokeNode unioned with the last StrokeNode, + // intersects the lasso bounding box. + // + // Now we need to iterate through the lasso points and find out where they cross + // + Point lastPoint = lassoLastPoint; + foreach (Point point in _points) + { + // + // calculate a segment of the lasso from the last point + // to the current point + // + Rect lassoSegmentBounds = new Rect(lastPoint, point); + + // + // see if this lasso segment intersects with the current stroke segment + // + if (!currentStrokeSegmentBounds.IntersectsWith(lassoSegmentBounds)) + { + lastPoint = point; + continue; + } + + // + // the lasso segment DOES intersect with the current stroke segment + // find out precisely where + // + StrokeFIndices strokeFIndices = strokeNode.CutTest(lastPoint, point); + + lastPoint = point; + if (strokeFIndices.IsEmpty) + { + // current lasso segment does not hit the stroke segment, continue with the next lasso point + continue; + } + + // Create a potentially new crossing for the current hit testing result. + LassoCrossing potentialNewCrossing = new LassoCrossing(strokeFIndices, strokeNode); + + // Try to merge with the current crossing. If the merge is succussful (return true), the new crossing is actually + // continueing the current crossing, so do not start a new crossing. Otherwise, start a new one and add the existing + // one to the list. + if (!currentCrossing.Merge(potentialNewCrossing)) + { + // start a new crossing and add the existing on to the list + crossingList.Add(currentCrossing); + currentCrossing = potentialNewCrossing; + } + } + } + + // Continue with the next node + currentStrokeSegmentBounds = nodeBounds; + lastNodePosition = strokeNode.Position; + } + + + // Adding the last crossing to the list, if valid + if (!currentCrossing.IsEmpty) + { + crossingList.Add(currentCrossing); + } + + // Handle the special case of no intersection at all + if (crossingList.Count == 0) + { + // the stroke was either completely inside the lasso + // or outside the lasso + if (this.Contains(lastNodePosition)) + { + StrokeIntersection[] strokeIntersections = new StrokeIntersection[1]; + strokeIntersections[0] = StrokeIntersection.Full; + return strokeIntersections; + } + else + { + return Array.Empty(); + } + } + + // It is still possible that the current crossing list is not sorted or overlapping. + // Sort the list and merge the overlapping ones. + SortAndMerge(ref crossingList); + + // Produce the hit test results and store them in a list + List strokeIntersectionList = new List(); + ProduceHitTestResults(crossingList, strokeIntersectionList); + + return strokeIntersectionList.ToArray(); + } + + /// + /// Sort and merge the crossing list + /// + /// The crossing list to sort/merge + private static void SortAndMerge(ref List crossingList) + { + // Sort the crossings based on the BeginFIndex values + crossingList.Sort(); + + List mergedList = new List(); + LassoCrossing mcrossing = LassoCrossing.EmptyCrossing; + foreach (LassoCrossing crossing in crossingList) + { + System.Diagnostics.Debug.Assert(!crossing.IsEmpty && crossing.StartNode.IsValid && crossing.EndNode.IsValid); + if (!mcrossing.Merge(crossing)) + { + System.Diagnostics.Debug.Assert(!mcrossing.IsEmpty && mcrossing.StartNode.IsValid && mcrossing.EndNode.IsValid); + mergedList.Add(mcrossing); + mcrossing = crossing; + } + } + if (!mcrossing.IsEmpty) + { + System.Diagnostics.Debug.Assert(!mcrossing.IsEmpty && mcrossing.StartNode.IsValid && mcrossing.EndNode.IsValid); + mergedList.Add(mcrossing); + } + crossingList = mergedList; + } + + + /// + /// Helper function to find out whether a point is inside the lasso + /// + private bool SegmentWithinLasso(StrokeNode strokeNode, double fIndex) + { + bool currentSegmentWithinLasso; + if (DoubleUtil.AreClose(fIndex, StrokeFIndices.BeforeFirst)) + { + // This should check against the very first stroke node + currentSegmentWithinLasso = this.Contains(strokeNode.GetPointAt(0f)); + } + else if (DoubleUtil.AreClose(fIndex, StrokeFIndices.AfterLast)) + { + // This should check against the last stroke node + currentSegmentWithinLasso = this.Contains(strokeNode.Position); + } + else + { + currentSegmentWithinLasso = this.Contains(strokeNode.GetPointAt(fIndex)); + } + + return currentSegmentWithinLasso; + } + + /// + /// Helper function to find out the hit test result + /// + private void ProduceHitTestResults( + List crossingList, List strokeIntersections) + { + bool previousSegmentInsideLasso = false; + for (int x = 0; x <= crossingList.Count; x++) + { + bool currentSegmentWithinLasso = false; + bool canMerge = true; + StrokeIntersection si = new StrokeIntersection(); + if (x == 0) + { + si.HitBegin = StrokeFIndices.BeforeFirst; + si.InBegin = StrokeFIndices.BeforeFirst; + } + else + { + si.InBegin = crossingList[x - 1].FIndices.EndFIndex; + si.HitBegin = crossingList[x - 1].FIndices.BeginFIndex; + currentSegmentWithinLasso = SegmentWithinLasso(crossingList[x - 1].EndNode, si.InBegin); + } + + if (x == crossingList.Count) + { + // For a special case when the last intersection is something like (1.2, AL). + // As a result the last InSegment should be empty. + if (DoubleUtil.AreClose(si.InBegin, StrokeFIndices.AfterLast)) + { + si.InEnd = StrokeFIndices.BeforeFirst; + } + else + { + si.InEnd = StrokeFIndices.AfterLast; + } + si.HitEnd = StrokeFIndices.AfterLast; + } + else + { + si.InEnd = crossingList[x].FIndices.BeginFIndex; + + // For a speical case when the first intersection is something like (BF, 0.67). + // As a result the first InSegment should be empty + if (DoubleUtil.AreClose(si.InEnd, StrokeFIndices.BeforeFirst)) + { + System.Diagnostics.Debug.Assert(DoubleUtil.AreClose(si.InBegin, StrokeFIndices.BeforeFirst)); + si.InBegin = StrokeFIndices.AfterLast; + } + + si.HitEnd = crossingList[x].FIndices.EndFIndex; + currentSegmentWithinLasso = SegmentWithinLasso(crossingList[x].StartNode, si.InEnd); + + // If both the start and end position of the current crossing is + // outside the lasso, the crossing is a hit-only intersection, i.e., the in-segment is empty. + if (!currentSegmentWithinLasso && !SegmentWithinLasso(crossingList[x].EndNode, si.HitEnd)) + { + currentSegmentWithinLasso = true; + si.HitBegin = crossingList[x].FIndices.BeginFIndex; + si.InBegin = StrokeFIndices.AfterLast; + si.InEnd = StrokeFIndices.BeforeFirst; + canMerge = false; + } + } + + if (currentSegmentWithinLasso) + { + if (x > 0 && previousSegmentInsideLasso && canMerge) + { + // we need to consolidate with the previous segment + StrokeIntersection previousIntersection = strokeIntersections[strokeIntersections.Count - 1]; + + // For example: previousIntersection = [BF, AL, BF, 0.0027], si = [BF, 0.0027, 0.049, 0.063] + if (previousIntersection.InSegment.IsEmpty) + { + previousIntersection.InBegin = si.InBegin; + } + previousIntersection.InEnd = si.InEnd; + previousIntersection.HitEnd = si.HitEnd; + strokeIntersections[strokeIntersections.Count - 1] = previousIntersection; + } + else + { + strokeIntersections.Add(si); + } + + if (DoubleUtil.AreClose(si.HitEnd, StrokeFIndices.AfterLast)) + { + // The strokeIntersections already cover the end of the stroke. No need to continue. + return; + } + } + previousSegmentInsideLasso = currentSegmentWithinLasso; + } + } + + /// + /// This flag is set to true when a lasso point has been modified or removed + /// from the list, which will invalidate incremental lasso hitteting + /// + internal bool IsIncrementalLassoDirty + { + get + { + return _incrementalLassoDirty; + } + set + { + _incrementalLassoDirty = value; + } + } + + /// + /// Get a reference to the lasso points store + /// + protected List PointsList + { + get + { + return _points; + } + } + + /// + /// Filter out duplicate points (and maybe in the futuer colinear points). + /// Return true if the point should be filtered + /// + protected virtual bool Filter(Point point) + { + // First point should not be filtered + if (0 == _points.Count) + { + return false; + } + // ISSUE-2004/06/14-vsmirnov - If the new segment is collinear with the last one, + // don't add the point but modify the last point instead. + Point lastPoint = _points[_points.Count - 1]; + Vector vector = point - lastPoint; + + // The point will be filtered out, i.e. not added to the list, if the distance to the previous point is + // within the tolerance + return (Math.Abs(vector.X) < MinDistance && Math.Abs(vector.Y) < MinDistance); + } + + /// + /// Implemtnation of add point + /// + /// + protected virtual void AddPointImpl(Point point) + { + _points.Add(point); + _bounds.Union(point); + } + #endregion + + #region Fields + + private List _points; + private Rect _bounds = Rect.Empty; + private bool _incrementalLassoDirty = false; + private static readonly double MinDistance = 1.0; + + #endregion + + /// + /// Simple helper struct used to track where the lasso crosses a stroke + /// we should consider making this a class if generics perf is bad for structs + /// + private struct LassoCrossing : IComparable + { + internal StrokeFIndices FIndices; + internal StrokeNode StartNode; + internal StrokeNode EndNode; + + /// + /// Constructor + /// + /// + /// + public LassoCrossing(StrokeFIndices newFIndices, StrokeNode strokeNode) + { + System.Diagnostics.Debug.Assert(!newFIndices.IsEmpty); + System.Diagnostics.Debug.Assert(strokeNode.IsValid); + FIndices = newFIndices; + StartNode = EndNode = strokeNode; + } + + /// + /// ToString + /// + public override string ToString() + { + return FIndices.ToString(); + } + + /// + /// Construct an empty LassoCrossing + /// + public static LassoCrossing EmptyCrossing + { + get + { + LassoCrossing crossing = new LassoCrossing(); + crossing.FIndices = StrokeFIndices.Empty; + return crossing; + } + } + + /// + /// Return true if this crossing is an empty one; false otherwise + /// + public bool IsEmpty + { + get { return FIndices.IsEmpty; } + } + + /// + /// Implement the interface used for comparison + /// + /// + /// + public int CompareTo(object obj) + { + System.Diagnostics.Debug.Assert(obj is LassoCrossing); + LassoCrossing crossing = (LassoCrossing) obj; + if (crossing.IsEmpty && this.IsEmpty) + { + return 0; + } + else if (crossing.IsEmpty) + { + return 1; + } + else if (this.IsEmpty) + { + return -1; + } + else + { + return FIndices.CompareTo(crossing.FIndices); + } + } + + /// + /// Merge two crossings into one. + /// + /// + /// Return true if these two crossings are actually overlapping and merged; false otherwise + public bool Merge(LassoCrossing crossing) + { + if (crossing.IsEmpty) + { + return false; + } + + if (FIndices.IsEmpty && !crossing.IsEmpty) + { + FIndices = crossing.FIndices; + StartNode = crossing.StartNode; + EndNode = crossing.EndNode; + return true; + } + + if (DoubleUtil.GreaterThanOrClose(crossing.FIndices.EndFIndex, FIndices.BeginFIndex) && + DoubleUtil.GreaterThanOrClose(FIndices.EndFIndex, crossing.FIndices.BeginFIndex)) + { + if (DoubleUtil.LessThan(crossing.FIndices.BeginFIndex, FIndices.BeginFIndex)) + { + FIndices.BeginFIndex = crossing.FIndices.BeginFIndex; + StartNode = crossing.StartNode; + } + + if (DoubleUtil.GreaterThan(crossing.FIndices.EndFIndex, FIndices.EndFIndex)) + { + FIndices.EndFIndex = crossing.FIndices.EndFIndex; + EndNode = crossing.EndNode; + } + return true; + } + + return false; + } + } + } + #endregion + + + #region Single-Loop Lasso + + /// + /// Implement a special lasso that considers only the first loop + /// + internal class SingleLoopLasso : Lasso + { + /// + /// Default constructor + /// + internal SingleLoopLasso() : base() { } + + /// + /// Return true if the point will be filtered out and should NOT be added to the list + /// + protected override bool Filter(Point point) + { + List points = PointsList; + + // First point should not be filtered + if (0 == points.Count) + { + // Just add the new point to the lasso + return false; + } + + // Don't add this point if the lasso already has a loop; or + // if it's filtered by base class's filter. + if (true == _hasLoop || true == base.Filter(point)) + { + // Don't add this point to the lasso. + return true; + } + + double intersection = 0f; + + // Now check whether the line lastPoint->point intersect with the + // existing lasso. + + if (true == GetIntersectionWithExistingLasso(point, ref intersection)) + { + System.Diagnostics.Debug.Assert(intersection >= 0 && intersection <= points.Count - 2); + + if (intersection == points.Count - 2) + { + return true; + } + + // Adding the new point will form a loop + int i = (int) intersection; + + if (!DoubleUtil.AreClose(i, intersection)) + { + // Move points[i] to the intersection position + Point intersectionPoint = new Point(0, 0); + intersectionPoint.X = points[i].X + (intersection - i) * (points[i + 1].X - points[i].X); + intersectionPoint.Y = points[i].Y + (intersection - i) * (points[i + 1].Y - points[i].Y); + points[i] = intersectionPoint; + IsIncrementalLassoDirty = true; + } + + // Since the lasso has a self loop and the loop starts at points[i], points[0] to + // points[i-1] should be removed + if (i > 0) + { + points.RemoveRange(0, i /*count*/); // Remove points[0] to points[i-1] + IsIncrementalLassoDirty = true; + } + + if (true == IsIncrementalLassoDirty) + { + // Update the bounds + Rect bounds = Rect.Empty; + for (int j = 0; j < points.Count; j++) + { + bounds.Union(points[j]); + } + Bounds = bounds; + } + + // The lasso has a self_loop, any more points will be neglected. + _hasLoop = true; + + // Don't add this point to the lasso. + return true; + } + + // Just add the new point to the lasso + return false; + } + + protected override void AddPointImpl(Point point) + { + _prevBounds = Bounds; + base.AddPointImpl(point); + } + + /// + /// If the line _points[Count -1]->point insersect with the existing lasso, return true + /// and bIndex value is set to a doulbe value representing position of the intersection. + /// + private bool GetIntersectionWithExistingLasso(Point point, ref double bIndex) + { + List points = PointsList; + int count = points.Count; + + Rect newRect = new Rect(points[count - 1], point); + + if (false == _prevBounds.IntersectsWith(newRect)) + { + // The point is not contained in the bound of the existing lasso, no intersection. + return false; + } + + for (int i = 0; i < count - 2; i++) + { + Rect currRect = new Rect(points[i], points[i + 1]); + if (!currRect.IntersectsWith(newRect)) + { + continue; + } + + double s = FindIntersection(points[count - 1] - points[i], /*hitBegin*/ + point - points[i], /*hitEnd*/ + new Vector(0, 0), /*orgBegin*/ + points[i + 1] - points[i] /*orgEnd*/); + if (s >= 0 && s <= 1) + { + // Intersection found, adjust the fIndex + bIndex = i + s; + return true; + } + } + + // No intersection + return false; + } + + + /// + /// Finds the intersection between the segment [hitBegin, hitEnd] and the segment [orgBegin, orgEnd]. + /// + private static double FindIntersection(Vector hitBegin, Vector hitEnd, Vector orgBegin, Vector orgEnd) + { + System.Diagnostics.Debug.Assert(hitEnd != hitBegin && orgBegin != orgEnd); + + //---------------------------------------------------------------------- + // Source: http://isc.faqs.org/faqs/graphics/algorithms-faq/ + // Subject 1.03: How do I find intersections of 2 2D line segments? + // + // Let A,B,C,D be 2-space position vectors. Then the directed line + // segments AB & CD are given by: + // + // AB=A+r(B-A), r in [0,1] + // CD=C+s(D-C), s in [0,1] + // + // If AB & CD intersect, then + // + // A+r(B-A)=C+s(D-C), or Ax+r(Bx-Ax)=Cx+s(Dx-Cx) + // Ay+r(By-Ay)=Cy+s(Dy-Cy) for some r,s in [0,1] + // + // Solving the above for r and s yields + // + // (Ay-Cy)(Dx-Cx)-(Ax-Cx)(Dy-Cy) + // r = ----------------------------- (eqn 1) + // (Bx-Ax)(Dy-Cy)-(By-Ay)(Dx-Cx) + // + // (Ay-Cy)(Bx-Ax)-(Ax-Cx)(By-Ay) + // s = ----------------------------- (eqn 2) + // (Bx-Ax)(Dy-Cy)-(By-Ay)(Dx-Cx) + // + // Let P be the position vector of the intersection point, then + // + // P=A+r(B-A) or Px=Ax+r(Bx-Ax) and Py=Ay+r(By-Ay) + // + // By examining the values of r & s, you can also determine some + // other limiting conditions: + // If 0 <= r <= 1 && 0 <= s <= 1, intersection exists + // r < 0 or r > 1 or s < 0 or s > 1 line segments do not intersect + // If the denominator in eqn 1 is zero, AB & CD are parallel + // If the numerator in eqn 1 is also zero, AB & CD are collinear. + // If they are collinear, then the segments may be projected to the x- + // or y-axis, and overlap of the projected intervals checked. + // + // If the intersection point of the 2 lines are needed (lines in this + // context mean infinite lines) regardless whether the two line + // segments intersect, then + // If r > 1, P is located on extension of AB + // If r < 0, P is located on extension of BA + // If s > 1, P is located on extension of CD + // If s < 0, P is located on extension of DC + // Also note that the denominators of eqn 1 & 2 are identical. + // + // References: + // [O'Rourke (C)] pp. 249-51 + // [Gems III] pp. 199-202 "Faster Line Segment Intersection," + //---------------------------------------------------------------------- + + // Calculate the vectors. + Vector AB = orgEnd - orgBegin; // B - A + Vector CA = orgBegin - hitBegin; // A - C + Vector CD = hitEnd - hitBegin; // D - C + double det = Vector.Determinant(AB, CD); + + if (DoubleUtil.IsZero(det)) + { + // The segments are parallel. no intersection + return NoIntersection; + } + + double r = AdjustFIndex(Vector.Determinant(AB, CA) / det); + + if (r >= 0 && r <= 1) + { + // The line defined AB does cross the segment CD. + double s = AdjustFIndex(Vector.Determinant(CD, CA) / det); + if (s >= 0 && s <= 1) + { + // The crossing point is on the segment AB as well. + // Intersection found. + return s; + } + } + + // No intersection found + return NoIntersection; + } + + /// + /// Clears double's computation fuzz around 0 and 1 + /// + internal static double AdjustFIndex(double findex) + { + return DoubleUtil.IsZero(findex) ? 0 : (DoubleUtil.IsOne(findex) ? 1 : findex); + } + + private bool _hasLoop = false; + private Rect _prevBounds = Rect.Empty; + private static readonly double NoIntersection = StrokeFIndices.BeforeFirst; + } + #endregion +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/Quad.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/Quad.cs new file mode 100644 index 0000000..4c34b65 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/Quad.cs @@ -0,0 +1,97 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +using System; +using System.Collections.Generic; +using WpfInk.PresentationCore.System.Windows; + +namespace MS.Internal.Ink +{ + /// + /// A helper structure used in StrokeNode and StrokeNodeOperation implementations + /// to store endpoints of the quad connecting two nodes of a stroke. + /// The vertices of a quad are supposed to be clockwise with points A and D located + /// on the begin node and B and C on the end one. + /// + internal struct Quad + { + #region Statics + + private static readonly Quad s_empty = new Quad(new Point(0, 0), new Point(0, 0), new Point(0, 0), new Point(0, 0)); + + #endregion + + #region API + + /// Returns the static object representing an empty (unitialized) quad + internal static Quad Empty { get { return s_empty; } } + + /// Constructor + internal Quad(Point a, Point b, Point c, Point d) + { + _A = a; _B = b; _C = c; _D = d; + } + + /// The A vertex of the quad + internal Point A { get { return _A; } set { _A = value; } } + + /// The B vertex of the quad + internal Point B { get { return _B; } set { _B = value; } } + + /// The C vertex of the quad + internal Point C { get { return _C; } set { _C = value; } } + + /// The D vertex of the quad + internal Point D { get { return _D; } set { _D = value; } } + + // Returns quad's vertex by index where A is of the index 0, B - is 1, etc + internal Point this[int index] + { + get + { + switch (index) + { + case 0: return _A; + case 1: return _B; + case 2: return _C; + case 3: return _D; + default: + throw new IndexOutOfRangeException("index"); + } + } + } + + /// Tells whether the quad is invalid (empty) + internal bool IsEmpty + { + get { return (_A == _B) && (_C == _D); } + } + + internal void GetPoints(List pointBuffer) + { + pointBuffer.Add(_A); + pointBuffer.Add(_B); + pointBuffer.Add(_C); + pointBuffer.Add(_D); + } + + /// Returns the bounds of the quad + internal Rect Bounds + { + get { return IsEmpty ? Rect.Empty : Rect.Union(new Rect(_A, _B), new Rect(_C, _D)); } + } + + #endregion + + #region Fields + + private Point _A; + private Point _B; + private Point _C; + private Point _D; + + #endregion + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeFIndices.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeFIndices.cs new file mode 100644 index 0000000..3e30530 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeFIndices.cs @@ -0,0 +1,208 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +using System; +using System.Globalization; + +namespace MS.Internal.Ink +{ + #region StrokeFIndices + + /// + /// A helper struct that represents a fragment of a stroke spine. + /// + internal struct StrokeFIndices : IEquatable + { + #region Private statics + private static readonly StrokeFIndices s_empty = new StrokeFIndices(AfterLast, BeforeFirst); + private static readonly StrokeFIndices s_full = new StrokeFIndices(BeforeFirst, AfterLast); + #endregion + + #region Internal API + + /// + /// BeforeFirst + /// + /// + internal static double BeforeFirst { get { return double.MinValue; } } + + /// + /// AfterLast + /// + /// + internal static double AfterLast { get { return double.MaxValue; } } + + /// + /// StrokeFIndices + /// + /// beginFIndex + /// endFIndex + internal StrokeFIndices(double beginFIndex, double endFIndex) + { + _beginFIndex = beginFIndex; + _endFIndex = endFIndex; + } + + /// + /// BeginFIndex + /// + /// + internal double BeginFIndex + { + get { return _beginFIndex; } + set { _beginFIndex = value; } + } + + /// + /// EndFIndex + /// + /// + internal double EndFIndex + { + get { return _endFIndex; } + set { _endFIndex = value; } + } + + /// + /// ToString + /// + public override string ToString() + { + return "{" + GetStringRepresentation(_beginFIndex) + "," + GetStringRepresentation(_endFIndex) + "}"; + } + + /// + /// Equals + /// + /// + /// + public bool Equals(StrokeFIndices strokeFIndices) + { + return (strokeFIndices == this); + } + + /// + /// Equals + /// + /// + /// + public override bool Equals(Object obj) + { + // Check for null and compare run-time types + if (obj == null || GetType() != obj.GetType()) + return false; + return ((StrokeFIndices) obj == this); + } + + /// + /// GetHashCode + /// + /// + public override int GetHashCode() + { + return _beginFIndex.GetHashCode() ^ _endFIndex.GetHashCode(); + } + + /// + /// operator == + /// + /// + /// + /// + public static bool operator ==(StrokeFIndices sfiLeft, StrokeFIndices sfiRight) + { + return (DoubleUtil.AreClose(sfiLeft._beginFIndex, sfiRight._beginFIndex) + && DoubleUtil.AreClose(sfiLeft._endFIndex, sfiRight._endFIndex)); + } + + /// + /// operator != + /// + /// + /// + /// + public static bool operator !=(StrokeFIndices sfiLeft, StrokeFIndices sfiRight) + { + return !(sfiLeft == sfiRight); + } + + internal static string GetStringRepresentation(double fIndex) + { + if (DoubleUtil.AreClose(fIndex, StrokeFIndices.BeforeFirst)) + { + return "BeforeFirst"; + } + if (DoubleUtil.AreClose(fIndex, StrokeFIndices.AfterLast)) + { + return "AfterLast"; + } + return fIndex.ToString(CultureInfo.InvariantCulture); + } + + /// + /// + /// + internal static StrokeFIndices Empty { get { return s_empty; } } + + /// + /// + /// + internal static StrokeFIndices Full { get { return s_full; } } + + /// + /// + /// + internal bool IsEmpty { get { return DoubleUtil.GreaterThanOrClose(_beginFIndex, _endFIndex); } } + + /// + /// + /// + internal bool IsFull { get { return ((DoubleUtil.AreClose(_beginFIndex, BeforeFirst)) && (DoubleUtil.AreClose(_endFIndex, AfterLast))); } } + + +#if DEBUG + /// + /// + /// + private bool IsValid { get { return !double.IsNaN(_beginFIndex) && !double.IsNaN(_endFIndex) && _beginFIndex < _endFIndex; } } + +#endif + + /// + /// Compare StrokeFIndices based on the BeinFIndex + /// + /// + /// + internal int CompareTo(StrokeFIndices fIndices) + { +#if DEBUG + System.Diagnostics.Debug.Assert(!double.IsNaN(_beginFIndex) && !double.IsNaN(_endFIndex) && DoubleUtil.LessThan(_beginFIndex, _endFIndex)); +#endif + if (DoubleUtil.AreClose(BeginFIndex, fIndices.BeginFIndex)) + { + return 0; + } + else if (DoubleUtil.GreaterThan(BeginFIndex, fIndices.BeginFIndex)) + { + return 1; + } + else + { + return -1; + } + } + + #endregion + + #region Fields + + private double _beginFIndex; + private double _endFIndex; + + #endregion + } + + #endregion +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeIntersection.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeIntersection.cs new file mode 100644 index 0000000..0648f1f --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeIntersection.cs @@ -0,0 +1,255 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +using System; +using System.Collections.Generic; +using MS.Internal.Ink; + +namespace WpfInk.PresentationCore.System.Windows.Ink +{ + /// + /// A helper struct that represents a fragment of a stroke spine. + /// + internal struct StrokeIntersection + { + #region Private statics + private static StrokeIntersection s_empty = new StrokeIntersection(AfterLast, AfterLast, BeforeFirst, BeforeFirst); + private static StrokeIntersection s_full = new StrokeIntersection(BeforeFirst, BeforeFirst, AfterLast, AfterLast); + #endregion + + #region Public API + + /// + /// BeforeFirst + /// + /// + internal static double BeforeFirst { get { return StrokeFIndices.BeforeFirst; } } + + /// + /// AfterLast + /// + /// + internal static double AfterLast { get { return StrokeFIndices.AfterLast; } } + + /// + /// Constructor + /// + /// + /// + /// + /// + internal StrokeIntersection(double hitBegin, double inBegin, double inEnd, double hitEnd) + { + //ISSUE-2004/12/06-XiaoTu: should we validate the input? + _hitSegment = new StrokeFIndices(hitBegin, hitEnd); + _inSegment = new StrokeFIndices(inBegin, inEnd); + } + + /// + /// hitBeginFIndex + /// + /// + internal double HitBegin + { + set { _hitSegment.BeginFIndex = value; } + } + + /// + /// hitEndFIndex + /// + /// + internal double HitEnd + { + get { return _hitSegment.EndFIndex; } + set { _hitSegment.EndFIndex = value; } + } + + + /// + /// InBegin + /// + /// + internal double InBegin + { + get { return _inSegment.BeginFIndex; } + set { _inSegment.BeginFIndex = value; } + } + + /// + /// InEnd + /// + /// + internal double InEnd + { + get { return _inSegment.EndFIndex; } + set { _inSegment.EndFIndex = value; } + } + + /// + /// ToString + /// + public override string ToString() + { + return "{" + StrokeFIndices.GetStringRepresentation(_hitSegment.BeginFIndex) + "," + + StrokeFIndices.GetStringRepresentation(_inSegment.BeginFIndex) + "," + + StrokeFIndices.GetStringRepresentation(_inSegment.EndFIndex) + "," + + StrokeFIndices.GetStringRepresentation(_hitSegment.EndFIndex) + "}"; + } + + + /// + /// Equals + /// + /// + /// + public override bool Equals(Object obj) + { + // Check for null and compare run-time types + if (obj == null || GetType() != obj.GetType()) + return false; + return ((StrokeIntersection) obj == this); + } + + /// + /// GetHashCode + /// + /// + public override int GetHashCode() + { + return _hitSegment.GetHashCode() ^ _inSegment.GetHashCode(); + } + + + /// + /// operator == + /// + /// + /// + /// + public static bool operator ==(StrokeIntersection left, StrokeIntersection right) + { + return (left._hitSegment == right._hitSegment && left._inSegment == right._inSegment); + } + + /// + /// operator != + /// + /// + /// + /// + public static bool operator !=(StrokeIntersection left, StrokeIntersection right) + { + return !(left == right); + } + + #endregion + + #region Internal API + + /// + /// + /// + internal static StrokeIntersection Full { get { return s_full; } } + + /// + /// + /// + internal bool IsEmpty { get { return _hitSegment.IsEmpty; } } + + + /// + /// + /// + internal StrokeFIndices HitSegment + { + get { return _hitSegment; } + } + + /// + /// + /// + internal StrokeFIndices InSegment + { + get { return _inSegment; } + } + + #endregion + + #region Internal static methods + + /// + /// Get the "in-segments" of the intersections. + /// + internal static StrokeFIndices[] GetInSegments(StrokeIntersection[] intersections) + { + global::System.Diagnostics.Debug.Assert(intersections != null); + global::System.Diagnostics.Debug.Assert(intersections.Length > 0); + + List inFIndices = new List(intersections.Length); + for (int j = 0; j < intersections.Length; j++) + { + global::System.Diagnostics.Debug.Assert(!intersections[j].IsEmpty); + if (!intersections[j].InSegment.IsEmpty) + { + if (inFIndices.Count > 0 && + inFIndices[inFIndices.Count - 1].EndFIndex >= + intersections[j].InSegment.BeginFIndex) + { + //merge + StrokeFIndices sfiPrevious = inFIndices[inFIndices.Count - 1]; + sfiPrevious.EndFIndex = intersections[j].InSegment.EndFIndex; + inFIndices[inFIndices.Count - 1] = sfiPrevious; + } + else + { + inFIndices.Add(intersections[j].InSegment); + } + } + } + return inFIndices.ToArray(); + } + + /// + /// Get the "hit-segments" + /// + internal static StrokeFIndices[] GetHitSegments(StrokeIntersection[] intersections) + { + global::System.Diagnostics.Debug.Assert(intersections != null); + global::System.Diagnostics.Debug.Assert(intersections.Length > 0); + + List hitFIndices = new List(intersections.Length); + for (int j = 0; j < intersections.Length; j++) + { + global::System.Diagnostics.Debug.Assert(!intersections[j].IsEmpty); + if (!intersections[j].HitSegment.IsEmpty) + { + if (hitFIndices.Count > 0 && + hitFIndices[hitFIndices.Count - 1].EndFIndex >= + intersections[j].HitSegment.BeginFIndex) + { + //merge + StrokeFIndices sfiPrevious = hitFIndices[hitFIndices.Count - 1]; + sfiPrevious.EndFIndex = intersections[j].HitSegment.EndFIndex; + hitFIndices[hitFIndices.Count - 1] = sfiPrevious; + } + else + { + hitFIndices.Add(intersections[j].HitSegment); + } + } + } + return hitFIndices.ToArray(); + } + + #endregion + + #region Fields + + private StrokeFIndices _hitSegment; + private StrokeFIndices _inSegment; + + #endregion + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeNode.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeNode.cs new file mode 100644 index 0000000..ae0a44a --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeNode.cs @@ -0,0 +1,1106 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +//#define DEBUG_RENDERING_FEEDBACK + +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Input; +using System.Diagnostics; +using WpfInk.PresentationCore.System.Windows; + +namespace MS.Internal.Ink +{ + #region StrokeNode + + /// + /// StrokeNode represents a single segment on a stroke spine. + /// It's used in enumerating through basic geometries making a stroke contour. + /// + internal struct StrokeNode + { + #region Constructors + + /// + /// Constructor. + /// + /// StrokeNodeOperations object created for particular rendering + /// Index of the node on the stroke spine + /// StrokeNodeData for this node + /// StrokeNodeData for the precedeng node + /// Whether the current node is the last node + internal StrokeNode( + StrokeNodeOperations operations, + int index, + in StrokeNodeData nodeData, + in StrokeNodeData lastNodeData, + bool isLastNode) + { + System.Diagnostics.Debug.Assert(operations != null); + System.Diagnostics.Debug.Assert((nodeData.IsEmpty == false) && (index >= 0)); + + + _operations = operations; + _index = index; + _thisNode = nodeData; + _lastNode = lastNodeData; + _isQuadCached = lastNodeData.IsEmpty; + _connectingQuad = Quad.Empty; + _isLastNode = isLastNode; + } + + #endregion + + #region Public API + + /// + /// Position of the node on the stroke spine. + /// + /// + internal Point Position { get { return _thisNode.Position; } } + + /// + /// Position of the previous StrokeNode + /// + /// + internal Point PreviousPosition { get { return _lastNode.Position; } } + + /// + /// PressureFactor of the node on the stroke spine. + /// + /// + internal float PressureFactor { get { return _thisNode.PressureFactor; } } + + /// + /// PressureFactor of the previous StrokeNode + /// + /// + internal float PreviousPressureFactor { get { return _lastNode.PressureFactor; } } + + /// + /// Tells whether the node shape (the stylus shape used in the rendering) + /// is elliptical or polygonal. If the shape is an ellipse, GetContourPoints + /// returns the control points for the quadratic Bezier that defines the ellipse. + /// + /// true if the shape is ellipse, false otherwise + internal bool IsEllipse { get { return IsValid && _operations.IsNodeShapeEllipse; } } + + /// + /// Returns true if this is the last node in the enumerator + /// + internal bool IsLastNode { get { return _isLastNode; } } + + /// + /// Returns the bounds of the node shape w/o connecting quadrangle + /// + /// + internal Rect GetBounds() + { + return IsValid ? _operations.GetNodeBounds(_thisNode) : Rect.Empty; + } + + /// + /// Returns the bounds of the node shape and connecting quadrangle + /// + /// + internal Rect GetBoundsConnected() + { + return IsValid ? Rect.Union(_operations.GetNodeBounds(_thisNode), ConnectingQuad.Bounds) : Rect.Empty; + } + + /// + /// Returns the points that make up the stroke node shape (minus the connecting quad) + /// + internal void GetContourPoints(List pointBuffer) + { + if (IsValid) + { + _operations.GetNodeContourPoints(_thisNode, pointBuffer); + } + } + + /// + /// Returns the points that make up the stroke node shape (minus the connecting quad) + /// + internal void GetPreviousContourPoints(List pointBuffer) + { + if (IsValid) + { + _operations.GetNodeContourPoints(_lastNode, pointBuffer); + } + } + + /// + /// Returns the connecting quad + /// + internal Quad GetConnectingQuad() + { + if (IsValid) + { + return ConnectingQuad; + } + return Quad.Empty; + } + + ///// + ///// IsPointWithinRectOrEllipse + ///// + //internal bool IsPointWithinRectOrEllipse(Point point, double xRadiusOrHalfWidth, double yRadiusOrHalfHeight, Point center, bool isEllipse) + //{ + // if (isEllipse) + // { + // //determine what delta is required to move the rect to be + // //centered at 0,0 + // double xDelta = center.X + xRadiusOrHalfWidth; + // double yDelta = center.Y + yRadiusOrHalfHeight; + + // //offset the point by that delta + // point.X -= xDelta; + // point.Y -= yDelta; + + // //formula for ellipse is x^2/a^2 + y^2/b^2 = 1 + // double a = xRadiusOrHalfWidth; + // double b = yRadiusOrHalfHeight; + // double res = (((point.X * point.X) / (a * a)) + + // ((point.Y * point.Y) / (b * b))); + + // if (res <= 1) + // { + // return true; + // } + // return false; + // } + // else + // { + // if (point.X >= (center.X - xRadiusOrHalfWidth) && + // point.X <= (center.X + xRadiusOrHalfWidth) && + // point.Y >= (center.Y - yRadiusOrHalfHeight) && + // point.Y <= (center.Y + yRadiusOrHalfHeight)) + // { + // return true; + // } + // return false; + // } + //} + + /// + /// GetPointsAtStartOfSegment + /// + internal void GetPointsAtStartOfSegment(List abPoints, + List dcPoints +#if DEBUG_RENDERING_FEEDBACK + , DrawingContext debugDC, double feedbackSize, bool showFeedback +#endif + ) + { + if (IsValid) + { + Quad quad = ConnectingQuad; + if (IsEllipse) + { + Rect startNodeBounds = _operations.GetNodeBounds(_lastNode); + + //add instructions to arc from D to A + abPoints.Add(quad.D); + abPoints.Add(StrokeRenderer.ArcToMarker); + abPoints.Add(new Point(startNodeBounds.Width, startNodeBounds.Height)); + abPoints.Add(quad.A); + + //simply start at D + dcPoints.Add(quad.D); + +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(null, new Pen(Brushes.Pink, feedbackSize / 2), _lastNode.Position, startNodeBounds.Width / 2, startNodeBounds.Height / 2); + debugDC.DrawEllipse(Brushes.Red, null, quad.A, feedbackSize, feedbackSize); + debugDC.DrawEllipse(Brushes.Blue, null, quad.D, feedbackSize, feedbackSize); + } +#endif + } + else + { + //we're interested in the A, D points as well as the + //nodecountour points between them + Rect endNodeRect = _operations.GetNodeBounds(_thisNode); + +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawRectangle(null, new Pen(Brushes.Pink, feedbackSize / 2), _operations.GetNodeBounds(_lastNode)); + } +#endif + Vector[] vertices = _operations.GetVertices(); + double pressureFactor = _lastNode.PressureFactor; + int maxCount = vertices.Length * 2; + int i = 0; + bool dIsInEndNode = true; + for (; i < maxCount; i++) + { + //look for the d point first + Point point = _lastNode.Position + (vertices[i % vertices.Length] * pressureFactor); + if (point == quad.D) + { + //ab always starts with the D position (only add if it's not in endNode's bounds) + if (!endNodeRect.Contains(quad.D)) + { + dIsInEndNode = false; + abPoints.Add(quad.D); + dcPoints.Add(quad.D); + } +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(Brushes.Blue, null, quad.D, feedbackSize, feedbackSize); + } +#endif + break; + } + } + + if (i == maxCount) + { + Debug.Assert(false, "StrokeNodeOperations.GetPointsAtStartOfSegment failed to find the D position"); + //we didn't find the d point, return + return; + } + + + //now look for the A position + //advance i + i++; + for (int j = 0; i < maxCount && j < vertices.Length; i++, j++) + { + //look for the A point now + Point point = _lastNode.Position + (vertices[i % vertices.Length] * pressureFactor); + //add everything in between to ab as long as it's not already in endNode's bounds + if (!endNodeRect.Contains(point)) + { + abPoints.Add(point); +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(Brushes.Wheat, null, point, feedbackSize, feedbackSize); + } +#endif + } + if (dIsInEndNode) + { + Debug.Assert(!endNodeRect.Contains(point)); + + //add the first point after d, clockwise + dIsInEndNode = false; + dcPoints.Add(point); + } + if (point == quad.A) + { +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(Brushes.Red, null, point, feedbackSize, feedbackSize); + } +#endif + break; + } + } + } + } + } + + + /// + /// GetPointsAtEndOfSegment + /// + internal void GetPointsAtEndOfSegment(List abPoints, + List dcPoints +#if DEBUG_RENDERING_FEEDBACK + , DrawingContext debugDC, double feedbackSize, bool showFeedback +#endif + ) + { + if (IsValid) + { + Quad quad = ConnectingQuad; + if (IsEllipse) + { + Rect bounds = GetBounds(); + //add instructions to arc from D to A + abPoints.Add(quad.B); + abPoints.Add(StrokeRenderer.ArcToMarker); + abPoints.Add(new Point(bounds.Width, bounds.Height)); + abPoints.Add(quad.C); + + //don't add to the dc points +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(null, new Pen(Brushes.Pink, feedbackSize / 2), _thisNode.Position, bounds.Width / 2, bounds.Height / 2); + debugDC.DrawEllipse(Brushes.Green, null, quad.B, feedbackSize, feedbackSize); + debugDC.DrawEllipse(Brushes.Yellow, null, quad.C, feedbackSize, feedbackSize); + } +#endif + } + else + { +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawRectangle(null, new Pen(Brushes.Pink, feedbackSize / 2), GetBounds()); + } +#endif + //we're interested in the B, C points as well as the + //nodecountour points between them + double pressureFactor = _thisNode.PressureFactor; + Vector[] vertices = _operations.GetVertices(); + int maxCount = vertices.Length * 2; + int i = 0; + for (; i < maxCount; i++) + { + //look for the d point first + Point point = _thisNode.Position + (vertices[i % vertices.Length] * pressureFactor); + if (point == quad.B) + { + abPoints.Add(quad.B); +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(Brushes.Green, null, point, feedbackSize, feedbackSize); + } +#endif + break; + } + } + + if (i == maxCount) + { + Debug.Assert(false, "StrokeNodeOperations.GetPointsAtEndOfSegment failed to find the B position"); + //we didn't find the d point, return + return; + } + + //now look for the C position + //advance i + i++; + for (int j = 0; i < maxCount && j < vertices.Length; i++, j++) + { + //look for the c point last + Point point = _thisNode.Position + (vertices[i % vertices.Length] * pressureFactor); + if (point == quad.C) + { + break; + } + //only add to ab if we didn't find C + abPoints.Add(point); + +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(Brushes.Wheat, null, quad.C, feedbackSize, feedbackSize); + } +#endif + } + //finally, add the D point + dcPoints.Add(quad.C); + +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(Brushes.Yellow, null, quad.C, feedbackSize, feedbackSize); + } +#endif + } + } + } + + /// + /// GetPointsAtMiddleSegment + /// + internal void GetPointsAtMiddleSegment(StrokeNode previous, + double angleBetweenNodes, + List abPoints, + List dcPoints, + out bool missingIntersection +#if DEBUG_RENDERING_FEEDBACK + , DrawingContext debugDC, double feedbackSize, bool showFeedback +#endif + ) + { + missingIntersection = false; + if (IsValid && previous.IsValid) + { + Quad quad1 = previous.ConnectingQuad; + if (!quad1.IsEmpty) + { + Quad quad2 = ConnectingQuad; + if (!quad2.IsEmpty) + { + if (IsEllipse) + { + Rect node1Bounds = _operations.GetNodeBounds(previous._lastNode); + Rect node2Bounds = _operations.GetNodeBounds(_lastNode); + Rect node3Bounds = _operations.GetNodeBounds(_thisNode); +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(null, new Pen(Brushes.Pink, feedbackSize / 2), _lastNode.Position, node2Bounds.Width / 2, node2Bounds.Height / 2); + } +#endif + if (angleBetweenNodes == 0.0d || ((quad1.B == quad2.A) && (quad1.C == quad2.D))) + { + //quads connections are the same, just add them + abPoints.Add(quad1.B); + dcPoints.Add(quad1.C); +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(Brushes.Green, null, quad1.B, feedbackSize, feedbackSize); + debugDC.DrawEllipse(Brushes.Yellow, null, quad1.C, feedbackSize, feedbackSize); + } +#endif + } + else if (angleBetweenNodes > 0.0) + { + //the stroke angled towards the AB side + //this part is easy + if (quad1.B == quad2.A) + { + abPoints.Add(quad1.B); +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(Brushes.Green, null, quad1.B, feedbackSize, feedbackSize); + } +#endif + } + else + { + Point intersection = GetIntersection(quad1.A, quad1.B, quad2.A, quad2.B); + Rect union = Rect.Union(node1Bounds, node2Bounds); + union.Inflate(1.0, 1.0); + //make sure we're not off in space + +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(Brushes.Green, null, quad1.B, feedbackSize * 1.5, feedbackSize * 1.5); + debugDC.DrawEllipse(Brushes.Red, null, quad2.A, feedbackSize, feedbackSize); + } +#endif + + if (union.Contains(intersection)) + { + abPoints.Add(intersection); +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(Brushes.Orange, null, intersection, feedbackSize, feedbackSize); + } +#endif + } + else + { + //if we missed the intersection we'll need to close the stroke segment + //this work is done in StrokeRenderer + missingIntersection = true; + return; //we're done. + } + } + + if (quad1.C == quad2.D) + { + dcPoints.Add(quad1.C); +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(Brushes.Yellow, null, quad1.C, feedbackSize, feedbackSize); + } +#endif + } + else + { + //add instructions to arc from quad1.C to quad2.D in reverse order (since we walk this array backwards to render) + dcPoints.Add(quad1.C); + dcPoints.Add(new Point(node2Bounds.Width, node2Bounds.Height)); + dcPoints.Add(StrokeRenderer.ArcToMarker); + dcPoints.Add(quad2.D); +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(Brushes.Yellow, null, quad1.C, feedbackSize, feedbackSize); + debugDC.DrawEllipse(Brushes.Blue, null, quad2.D, feedbackSize, feedbackSize); + } +#endif + } + } + else + { + //the stroke angled towards the CD side + //this part is easy + if (quad1.C == quad2.D) + { + dcPoints.Add(quad1.C); +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(Brushes.Yellow, null, quad1.C, feedbackSize, feedbackSize); + } +#endif + } + else + { + Point intersection = GetIntersection(quad1.D, quad1.C, quad2.D, quad2.C); + Rect union = Rect.Union(node1Bounds, node2Bounds); + union.Inflate(1.0, 1.0); + //make sure we're not off in space + +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(Brushes.Yellow, null, quad1.C, feedbackSize * 1.5, feedbackSize * 1.5); + debugDC.DrawEllipse(Brushes.Blue, null, quad2.D, feedbackSize, feedbackSize); + } +#endif + + if (union.Contains(intersection)) + { + dcPoints.Add(intersection); +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(Brushes.Orange, null, intersection, feedbackSize, feedbackSize); + } +#endif + } + else + { + //if we missed the intersection we'll need to close the stroke segment + //this work is done in StrokeRenderer + missingIntersection = true; + return; //we're done. + } + } + + if (quad1.B == quad2.A) + { + abPoints.Add(quad1.B); +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(Brushes.Green, null, quad1.B, feedbackSize, feedbackSize); + } +#endif + + } + else + { + //we need to arc between quad1.B and quad2.A along node2 + abPoints.Add(quad1.B); + abPoints.Add(StrokeRenderer.ArcToMarker); + abPoints.Add(new Point(node2Bounds.Width, node2Bounds.Height)); + abPoints.Add(quad2.A); +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(Brushes.Green, null, quad1.B, feedbackSize, feedbackSize); + debugDC.DrawEllipse(Brushes.Red, null, quad2.A, feedbackSize, feedbackSize); + } +#endif + } + } + } + else + { + //rectangle + int indexA = -1; + int indexB = -1; + int indexC = -1; + int indexD = -1; + + Vector[] vertices = _operations.GetVertices(); + double pressureFactor = _lastNode.PressureFactor; + for (int i = 0; i < vertices.Length; i++) + { + Point point = _lastNode.Position + (vertices[i % vertices.Length] * pressureFactor); + if (point == quad2.A) + { + indexA = i; + } + if (point == quad1.B) + { + indexB = i; + } + if (point == quad1.C) + { + indexC = i; + } + if (point == quad2.D) + { + indexD = i; + } + } + + if (indexA == -1 || indexB == -1 || indexC == -1 || indexD == -1) + { + Debug.Assert(false, "Couldn't find all 4 indexes in StrokeNodeOperations.GetPointsAtMiddleSegment"); + return; + } + +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + + debugDC.DrawRectangle(null, new Pen(Brushes.Pink, feedbackSize / 2), _operations.GetNodeBounds(_lastNode)); + debugDC.DrawEllipse(Brushes.Red, null, quad2.A, feedbackSize, feedbackSize); + debugDC.DrawEllipse(Brushes.Green, null, quad1.B, feedbackSize, feedbackSize); + debugDC.DrawEllipse(Brushes.Yellow, null, quad1.C, feedbackSize, feedbackSize); + debugDC.DrawEllipse(Brushes.Blue, null, quad2.D, feedbackSize, feedbackSize); + } +#endif + + Rect node3Rect = _operations.GetNodeBounds(_thisNode); + //take care of a-b first + if (indexA == indexB) + { + //quad connection is the same, just add it + if (!node3Rect.Contains(quad1.B)) + { + abPoints.Add(quad1.B); + } + } + else if ((indexA == 0 && indexB == 3) || ((indexA != 3 || indexB != 0) && (indexA > indexB))) + { + if (!node3Rect.Contains(quad1.B)) + { + abPoints.Add(quad1.B); + } + if (!node3Rect.Contains(quad2.A)) + { + abPoints.Add(quad2.A); + } + } + else + { + Point intersection = GetIntersection(quad1.A, quad1.B, quad2.A, quad2.B); + Rect node12 = Rect.Union(_operations.GetNodeBounds(previous._lastNode), _operations.GetNodeBounds(_lastNode)); + node12.Inflate(1.0, 1.0); + //make sure we're not off in space + if (node12.Contains(intersection)) + { + abPoints.Add(intersection); +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(Brushes.Orange, null, intersection, feedbackSize, feedbackSize * 1.5); + } +#endif + } + else + { + //if we missed the intersection we'll need to close the stroke segment + //this work is done in StrokeRenderer. + missingIntersection = true; + return; //we're done. + } + } + + // now take care of c-d. + if (indexC == indexD) + { + //quad connection is the same, just add it + if (!node3Rect.Contains(quad1.C)) + { + dcPoints.Add(quad1.C); + } + } + else if ((indexC == 0 && indexD == 3) || ((indexC != 3 || indexD != 0) && (indexC > indexD))) + { + if (!node3Rect.Contains(quad1.C)) + { + dcPoints.Add(quad1.C); + } + if (!node3Rect.Contains(quad2.D)) + { + dcPoints.Add(quad2.D); + } + } + else + { + Point intersection = GetIntersection(quad1.D, quad1.C, quad2.D, quad2.C); + Rect node12 = Rect.Union(_operations.GetNodeBounds(previous._lastNode), _operations.GetNodeBounds(_lastNode)); + node12.Inflate(1.0, 1.0); + //make sure we're not off in space + if (node12.Contains(intersection)) + { + dcPoints.Add(intersection); +#if DEBUG_RENDERING_FEEDBACK + if (showFeedback) + { + debugDC.DrawEllipse(Brushes.Orange, null, intersection, feedbackSize, feedbackSize * 1.5); + } +#endif + } + else + { + //if we missed the intersection we'll need to close the stroke segment + //this work is done in StrokeRenderer. + missingIntersection = true; + return; //we're done. + } + } + } + } + } + } + } + + /// + /// Returns the intersection between two lines. This code assumes there is an intersection + /// and should only be called if that assumption is valid + /// + /// + internal static Point GetIntersection(Point line1Start, Point line1End, Point line2Start, Point line2End) + { + double a1 = line1End.Y - line1Start.Y; + double b1 = line1Start.X - line1End.X; + double c1 = (line1End.X * line1Start.Y) - (line1Start.X * line1End.Y); + double a2 = line2End.Y - line2Start.Y; + double b2 = line2Start.X - line2End.X; + double c2 = (line2End.X * line2Start.Y) - (line2Start.X * line2End.Y); + + double d = (a1 * b2) - (a2 * b1); + if (d != 0.0) + { + double x = ((b1 * c2) - (b2 * c1)) / d; + double y = ((a2 * c1) - (a1 * c2)) / d; + + //capture the min and max points + double line1XMin, line1XMax, line1YMin, line1YMax, line2XMin, line2XMax, line2YMin, line2YMax; + if (line1Start.X < line1End.X) + { + line1XMin = Math.Floor(line1Start.X); + line1XMax = Math.Ceiling(line1End.X); + } + else + { + line1XMin = Math.Floor(line1End.X); + line1XMax = Math.Ceiling(line1Start.X); + } + + if (line2Start.X < line2End.X) + { + line2XMin = Math.Floor(line2Start.X); + line2XMax = Math.Ceiling(line2End.X); + } + else + { + line2XMin = Math.Floor(line2End.X); + line2XMax = Math.Ceiling(line2Start.X); + } + + if (line1Start.Y < line1End.Y) + { + line1YMin = Math.Floor(line1Start.Y); + line1YMax = Math.Ceiling(line1End.Y); + } + else + { + line1YMin = Math.Floor(line1End.Y); + line1YMax = Math.Ceiling(line1Start.Y); + } + + if (line2Start.Y < line2End.Y) + { + line2YMin = Math.Floor(line2Start.Y); + line2YMax = Math.Ceiling(line2End.Y); + } + else + { + line2YMin = Math.Floor(line2End.Y); + line2YMax = Math.Ceiling(line2Start.Y); + } + + + // now see if we have an intersection between the lines + // and not just the projection of the lines + if ((line1XMin <= x && x <= line1XMax) && + (line1YMin <= y && y <= line1YMax) && + (line2XMin <= x && x <= line2XMax) && + (line2YMin <= y && y <= line2YMax)) + { + return new Point(x, y); + } + } + + if ((long) line1End.X == (long) line2Start.X && + (long) line1End.Y == (long) line2Start.Y) + { + return new Point(line1End.X, line1End.Y); + } + + return new Point(Double.NaN, Double.NaN); + } + + /// + /// This method tells whether the contour of a given stroke node + /// intersects with the contour of this node. The contours of both nodes + /// include their connecting quadrangles. + /// + /// + /// + internal bool HitTest(StrokeNode hitNode) + { + if (!IsValid || !hitNode.IsValid) + { + return false; + } + + IEnumerable hittingContour = hitNode.GetContourSegments(); + + return _operations.HitTest(_lastNode, _thisNode, ConnectingQuad, hittingContour); + } + + /// + /// Finds out if a given node intersects with this one, + /// and returns findices of the intersection. + /// + /// + /// + internal StrokeFIndices CutTest(StrokeNode hitNode) + { + if ((IsValid == false) || (hitNode.IsValid == false)) + { + return StrokeFIndices.Empty; + } + + IEnumerable hittingContour = hitNode.GetContourSegments(); + + // If the node contours intersect, the result is a pair of findices + // this segment should be cut at to let the hitNode's contour through it. + StrokeFIndices cutAt = _operations.CutTest(_lastNode, _thisNode, ConnectingQuad, hittingContour); + + return (_index == 0) ? cutAt : BindFIndices(cutAt); + } + + /// + /// Finds out if a given linear segment intersects with the contour of this node + /// (including connecting quadrangle), and returns findices of the intersection. + /// + /// + /// + /// + internal StrokeFIndices CutTest(Point begin, Point end) + { + if (IsValid == false) + { + return StrokeFIndices.Empty; + } + + // If the node contours intersect, the result is a pair of findices + // this segment should be cut at to let the hitNode's contour through it. + StrokeFIndices cutAt = _operations.CutTest(_lastNode, _thisNode, ConnectingQuad, begin, end); + + System.Diagnostics.Debug.Assert(!double.IsNaN(cutAt.BeginFIndex) && !double.IsNaN(cutAt.EndFIndex)); + + // Bind the found findices to the node and return the result + return BindFIndicesForLassoHitTest(cutAt); + } + + #endregion + + #region Private helpers + + /// + /// Binds a local fragment to this node by setting the integer part of the + /// fragment findices equal to the index of the previous node + /// + /// + /// + private StrokeFIndices BindFIndices(StrokeFIndices fragment) + { + System.Diagnostics.Debug.Assert(IsValid && (_index >= 0)); + + if (fragment.IsEmpty == false) + { + // Adjust only findices which are on this segment of thew spine (i.e. between 0 and 1) + if (!DoubleUtil.AreClose(fragment.BeginFIndex, StrokeFIndices.BeforeFirst)) + { + System.Diagnostics.Debug.Assert(fragment.BeginFIndex >= 0 && fragment.BeginFIndex <= 1); + fragment.BeginFIndex += _index - 1; + } + if (!DoubleUtil.AreClose(fragment.EndFIndex, StrokeFIndices.AfterLast)) + { + System.Diagnostics.Debug.Assert(fragment.EndFIndex >= 0 && fragment.EndFIndex <= 1); + fragment.EndFIndex += _index - 1; + } + } + return fragment; + } + + internal int Index + { + get { return _index; } + } + + /// + /// Bind the StrokeFIndices for lasso hit test results. + /// + /// + /// + private StrokeFIndices BindFIndicesForLassoHitTest(StrokeFIndices fragment) + { + System.Diagnostics.Debug.Assert(IsValid); + if (!fragment.IsEmpty) + { + // Adjust BeginFIndex + if (DoubleUtil.AreClose(fragment.BeginFIndex, StrokeFIndices.BeforeFirst)) + { + // set it to be the index of the previous node, indicating intersection start from previous node + fragment.BeginFIndex = (_index == 0 ? StrokeFIndices.BeforeFirst : _index - 1); + } + else + { + // Adjust findices which are on this segment of the spine (i.e. between 0 and 1) + System.Diagnostics.Debug.Assert(DoubleUtil.GreaterThanOrClose(fragment.BeginFIndex, 0f)); + + System.Diagnostics.Debug.Assert(DoubleUtil.LessThanOrClose(fragment.BeginFIndex, 1f)); + + // Adjust the value to consider index, say from 0.75 to 3.75 (for _index = 4) + fragment.BeginFIndex += _index - 1; + } + + //Adjust EndFIndex + if (DoubleUtil.AreClose(fragment.EndFIndex, StrokeFIndices.AfterLast)) + { + // set it to be the index of the current node, indicating the intersection cover the end of the node + fragment.EndFIndex = (_isLastNode ? StrokeFIndices.AfterLast : _index); + } + else + { + System.Diagnostics.Debug.Assert(DoubleUtil.GreaterThanOrClose(fragment.EndFIndex, 0f)); + + System.Diagnostics.Debug.Assert(DoubleUtil.LessThanOrClose(fragment.EndFIndex, 1f)); + // Ajust the value to consider the index + fragment.EndFIndex += _index - 1; + } + } + return fragment; + } + + /// + /// Tells whether the StrokeNode instance is valid or not (created via the default ctor) + /// + internal bool IsValid { get { return _operations != null; } } + + /// + /// The quadrangle that connects this and the previous node. + /// Can be empty if this node is the first one or if one of the nodes is + /// completely inside the other. + /// The type Quad is supposed to be internal even if we surface StrokeNode. + /// External users of StrokeNode should use GetConnectionPoints instead. + /// + private Quad ConnectingQuad + { + get + { + System.Diagnostics.Debug.Assert(IsValid); + + if (_isQuadCached == false) + { + _connectingQuad = _operations.GetConnectingQuad(_lastNode, _thisNode); + _isQuadCached = true; + } + return _connectingQuad; + } + } + + /// + /// Returns an enumerator for edges of the contour comprised by the node + /// and connecting quadrangle (_lastNode is excluded) + /// Used for hit-testing a stroke against an other stroke (stroke and point erasing) + /// + private IEnumerable GetContourSegments() + { + System.Diagnostics.Debug.Assert(IsValid); + + // Calls thru to the StrokeNodeOperations object + if (IsEllipse) + { + // ISSUE-2004/06/15- temporary workaround to avoid hit-testing with ellipses + return _operations.GetNonBezierContourSegments(_lastNode, _thisNode); + } + return _operations.GetContourSegments(_thisNode, ConnectingQuad); + } + + /// + /// Returns the spine point that corresponds to the given findex. + /// + /// A local findex between the previous index and this one (ex: between 2.0 and 3.0) + /// Point on the spine + internal Point GetPointAt(double findex) + { + System.Diagnostics.Debug.Assert(IsValid); + + if (_lastNode.IsEmpty) + { + System.Diagnostics.Debug.Assert(findex == 0); + return _thisNode.Position; + } + + System.Diagnostics.Debug.Assert((findex >= _index - 1) && (findex <= _index)); + + if (DoubleUtil.AreClose(findex, (double) _index)) + { + // + // we're being asked for this exact point + // if we don't return it here, our algorithm + // below doesn't work + // + return _thisNode.Position; + } + + // + // get the spare change to the left of the decimal point + // eg turn 2.75 into .75 + // + double floor = Math.Floor(findex); + findex = findex - floor; + + double xDiff = (_thisNode.Position.X - _lastNode.Position.X) * findex; + double yDiff = (_thisNode.Position.Y - _lastNode.Position.Y) * findex; + + // + // return the previous point plus the delta's + // + return new Point(_lastNode.Position.X + xDiff, + _lastNode.Position.Y + yDiff); + } + + #endregion + + #region Fields + + // Internal objects created for particular rendering + private StrokeNodeOperations _operations; + + // Node's index on the stroke spine + private int _index; + + // This and the previous node data that used by the StrokeNodeOperations object to build + // and/or hit-test the contour of the node/segment + private StrokeNodeData _thisNode; + private StrokeNodeData _lastNode; + + // Calculating of the connecting quadrangle is not a cheap operations, therefore, + // first, it's computed only by request, and second, once computed it's cached in the StrokeNode + private bool _isQuadCached; + private Quad _connectingQuad; + + // Is the current stroke node the last node? + private bool _isLastNode; + + #endregion + } + #endregion +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeNodeData.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeNodeData.cs new file mode 100644 index 0000000..7fdd508 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeNodeData.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Input; +using System.Diagnostics; +using WpfInk.PresentationCore.System.Windows; + +namespace MS.Internal.Ink +{ + #region StrokeNodeData + + /// + /// This structure represents a node on a stroke spine. + /// + internal readonly struct StrokeNodeData + { + #region Statics + + private static readonly StrokeNodeData s_empty = new StrokeNodeData(); + + #endregion + + #region API (internal) + + /// Returns static object representing an unitialized node + internal static StrokeNodeData Empty { get { return s_empty; } } + + /// + /// Constructor for nodes of a pressure insensitive stroke + /// + /// position of the node + internal StrokeNodeData(Point position) + { + _position = position; + _pressure = 1; + } + + /// + /// Constructor for nodes with pressure data + /// + /// position of the node + /// pressure scaling factor at the node + internal StrokeNodeData(Point position, float pressure) + { + System.Diagnostics.Debug.Assert(DoubleUtil.GreaterThan((double) pressure, 0d)); + + _position = position; + _pressure = pressure; + } + + /// Tells whether the structre was properly initialized + internal bool IsEmpty + { + get + { + Debug.Assert(DoubleUtil.AreClose(0, s_empty._pressure)); + return DoubleUtil.AreClose(_pressure, s_empty._pressure); + } + } + + /// Position of the node + internal Point Position + { + get { return _position; } + } + + /// Pressure scaling factor at the node + internal float PressureFactor { get { return _pressure; } } + + #endregion + + #region Privates + + private readonly Point _position; + private readonly float _pressure; + + #endregion + } + + #endregion +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeNodeEnumerator.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeNodeEnumerator.cs new file mode 100644 index 0000000..9fcfb67 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeNodeEnumerator.cs @@ -0,0 +1,248 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using WpfInk.PresentationCore.System.Windows; +using WpfInk.PresentationCore.System.Windows.Ink; +using WpfInk.PresentationCore.System.Windows.Input.Stylus; + +namespace MS.Internal.Ink +{ + /// + /// This class serves as a unified tool for enumerating through stroke nodes + /// for all kinds of rendering and/or hit-testing that uses stroke contours. + /// It provides static API for static (atomic) rendering, and it needs to be + /// instantiated for dynamic (incremental) rendering. It generates stroke nodes + /// from Stroke objects with or w/o overriden drawing attributes, as well as from + /// a arrays of points (for a given StylusShape), and from raw stylus packets. + /// In either case, the output collection of nodes is represented by a disposable + /// iterator (i.e. good for a single enumeration only). + /// + internal class StrokeNodeIterator + { + /// + /// Helper wrapper + /// + public static StrokeNodeIterator GetIterator(Stroke stroke, DrawingAttributes drawingAttributes) + { + if (stroke == null) + { + throw new System.ArgumentNullException("stroke"); + } + if (drawingAttributes == null) + { + throw new System.ArgumentNullException("drawingAttributes"); + } + + StylusPointCollection stylusPoints = + drawingAttributes.FitToCurve ? stroke.GetBezierStylusPoints() : stroke.StylusPoints; + + return GetIterator(stylusPoints, drawingAttributes); + } + /// + /// Creates a default enumerator for a given stroke + /// If using the strokes drawing attributes, pass stroke.DrawingAttributes for the second + /// argument. If using an overridden DA, use that instance. + /// + internal static StrokeNodeIterator GetIterator(StylusPointCollection stylusPoints, DrawingAttributes drawingAttributes) + { + if (stylusPoints == null) + { + throw new System.ArgumentNullException("stylusPoints"); + } + if (drawingAttributes == null) + { + throw new System.ArgumentNullException("drawingAttributes"); + } + + StrokeNodeOperations operations = + StrokeNodeOperations.CreateInstance(drawingAttributes.StylusShape); + + bool usePressure = !drawingAttributes.IgnorePressure; + + return new StrokeNodeIterator(stylusPoints, operations, usePressure); + } + + + /// + /// GetNormalizedPressureFactor + /// + private static float GetNormalizedPressureFactor(float stylusPointPressureFactor) + { + // + // create a compatible pressure value that maps 0-1 to 0.25 - 1.75 + // + return (1.5f * stylusPointPressureFactor) + 0.25f; + } + + /// + /// Constructor for an incremental node enumerator that builds nodes + /// from array(s) of points and a given stylus shape. + /// + /// a shape that defines the stroke contour + internal StrokeNodeIterator(StylusShape nodeShape) + : this(null, //stylusPoints + StrokeNodeOperations.CreateInstance(nodeShape), + false) //usePressure) + { + } + + /// + /// Constructor for an incremental node enumerator that builds nodes + /// from StylusPointCollections + /// called by the IncrementalRenderer + /// + /// drawing attributes + internal StrokeNodeIterator(DrawingAttributes drawingAttributes) + : this(null, //stylusPoints + StrokeNodeOperations.CreateInstance((drawingAttributes == null ? null : drawingAttributes.StylusShape)), + (drawingAttributes == null ? false : !drawingAttributes.IgnorePressure)) //usePressure + { + } + + /// + /// Private ctor + /// + /// + /// + /// + internal StrokeNodeIterator(StylusPointCollection stylusPoints, + StrokeNodeOperations operations, + bool usePressure) + { + //Note, StylusPointCollection can be null + _stylusPoints = stylusPoints; + if (operations == null) + { + throw new ArgumentNullException("operations"); + } + _operations = operations; + _usePressure = usePressure; + } + + /// + /// Generates (enumerates) StrokeNode objects for a stroke increment + /// represented by an StylusPointCollection. Called from IncrementalRenderer + /// + /// StylusPointCollection + /// yields StrokeNode objects one by one + internal StrokeNodeIterator GetIteratorForNextSegment(StylusPointCollection stylusPoints) + { + if (stylusPoints == null) + { + throw new System.ArgumentNullException("stylusPoints"); + } + + if (_stylusPoints != null && _stylusPoints.Count > 0 && stylusPoints.Count > 0) + { + //insert the previous last point, but we need insert a compatible + //previous point. The easiest way to do this is to clone a point + //(since StylusPoint is a struct, we get get one out to get a copy + StylusPoint sp = stylusPoints[0]; + StylusPoint lastStylusPoint = _stylusPoints[_stylusPoints.Count - 1]; + sp.X = lastStylusPoint.X; + sp.Y = lastStylusPoint.Y; + sp.PressureFactor = lastStylusPoint.PressureFactor; + stylusPoints.Insert(0, sp); + } + + return new StrokeNodeIterator(stylusPoints, + _operations, + _usePressure); + } + + /// + /// Generates (enumerates) StrokeNode objects for a stroke increment + /// represented by an array of points. This method is supposed to be used only + /// on objects created via the c-tor with a StylusShape parameter. + /// + /// an array of points representing a stroke increment + /// yields StrokeNode objects one by one + internal StrokeNodeIterator GetIteratorForNextSegment(Point[] points) + { + if (points == null) + { + throw new System.ArgumentNullException("points"); + } + StylusPointCollection newStylusPoints = new StylusPointCollection(points); + if (_stylusPoints != null && _stylusPoints.Count > 0) + { + //insert the previous last point + newStylusPoints.Insert(0, _stylusPoints[_stylusPoints.Count - 1]); + } + + return new StrokeNodeIterator(newStylusPoints, + _operations, + _usePressure); + } + + /// + /// The count of strokenodes that can be iterated across + /// + internal int Count + { + get + { + if (_stylusPoints == null) + { + return 0; + } + return _stylusPoints.Count; + } + } + + /// + /// Gets a StrokeNode at the specified index + /// + /// + /// + internal StrokeNode this[int index] + { + get + { + return this[index, (index == 0 ? -1 : index - 1)]; + } + } + + /// + /// Gets a StrokeNode at the specified index that connects to a stroke at the previousIndex + /// previousIndex can be -1 to signify it should be empty (first strokeNode) + /// + /// + internal StrokeNode this[int index, int previousIndex] + { + get + { + if (_stylusPoints == null || index < 0 || index >= _stylusPoints.Count || previousIndex < -1 || previousIndex >= index) + { + throw new IndexOutOfRangeException(); + } + + StylusPoint stylusPoint = _stylusPoints[index]; + StylusPoint previousStylusPoint = (previousIndex == -1 ? new StylusPoint() : _stylusPoints[previousIndex]); + float pressureFactor = 1.0f; + float previousPressureFactor = 1.0f; + if (_usePressure) + { + pressureFactor = StrokeNodeIterator.GetNormalizedPressureFactor(stylusPoint.PressureFactor); + previousPressureFactor = StrokeNodeIterator.GetNormalizedPressureFactor(previousStylusPoint.PressureFactor); + } + + StrokeNodeData nodeData = new StrokeNodeData((Point) stylusPoint, pressureFactor); + StrokeNodeData lastNodeData = StrokeNodeData.Empty; + if (previousIndex != -1) + { + lastNodeData = new StrokeNodeData((Point) previousStylusPoint, previousPressureFactor); + } + + //we use previousIndex+1 because index can skip ahead + return new StrokeNode(_operations, previousIndex + 1, nodeData, lastNodeData, index == _stylusPoints.Count - 1 /*Is this the last node?*/); + } + } + + private bool _usePressure; + private StrokeNodeOperations _operations; + private StylusPointCollection _stylusPoints; + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeNodeOperations.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeNodeOperations.cs new file mode 100644 index 0000000..0865b5e --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeNodeOperations.cs @@ -0,0 +1,1331 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +using System; +using System.Collections.Generic; +using WpfInk.PresentationCore.System.Windows; +using WpfInk.PresentationCore.System.Windows.Ink; + +namespace MS.Internal.Ink +{ + /// + /// The base operations class that implements polygonal node operations by default. + /// + internal partial class StrokeNodeOperations + { + #region Static API + + /// + /// + /// + /// + /// + internal static StrokeNodeOperations CreateInstance(StylusShape nodeShape) + { + if (nodeShape == null) + { + throw new ArgumentNullException("nodeShape"); + } + if (nodeShape.IsEllipse) + { + return new EllipticalNodeOperations(nodeShape); + } + return new StrokeNodeOperations(nodeShape); + } + #endregion + + #region API + + /// + /// Constructor + /// + /// shape of the nodes + internal StrokeNodeOperations(StylusShape nodeShape) + { + System.Diagnostics.Debug.Assert(nodeShape != null); + _vertices = nodeShape.GetVerticesAsVectors(); + } + + /// + /// This is probably not the best (design-wise) but the cheapest way to tell + /// EllipticalNodeOperations from all other implementations of node operations. + /// + internal virtual bool IsNodeShapeEllipse { get { return false; } } + + /// + /// Computes the bounds of a node + /// + /// node to compute bounds of + /// bounds of the node + internal Rect GetNodeBounds(in StrokeNodeData node) + { + if (_shapeBounds.IsEmpty) + { + int i; + for (i = 0; (i + 1) < _vertices.Length; i += 2) + { + _shapeBounds.Union(new Rect((Point) _vertices[i], (Point) _vertices[i + 1])); + } + if (i < _vertices.Length) + { + _shapeBounds.Union((Point) _vertices[i]); + } + } + + Rect boundingBox = _shapeBounds; + System.Diagnostics.Debug.Assert((boundingBox.X <= 0) && (boundingBox.Y <= 0)); + + double pressureFactor = node.PressureFactor; + if (!DoubleUtil.AreClose(pressureFactor, 1d)) + { + boundingBox = new Rect( + _shapeBounds.X * pressureFactor, + _shapeBounds.Y * pressureFactor, + _shapeBounds.Width * pressureFactor, + _shapeBounds.Height * pressureFactor); + } + + boundingBox.Location += (Vector) node.Position; + + return boundingBox; + } + + internal void GetNodeContourPoints(in StrokeNodeData node, List pointBuffer) + { + double pressureFactor = node.PressureFactor; + if (DoubleUtil.AreClose(pressureFactor, 1d)) + { + for (int i = 0; i < _vertices.Length; i++) + { + pointBuffer.Add(node.Position + _vertices[i]); + } + } + else + { + for (int i = 0; i < _vertices.Length; i++) + { + pointBuffer.Add(node.Position + (_vertices[i] * pressureFactor)); + } + } + } + + /// + /// Returns an enumerator for edges of the contour comprised by a given node + /// and its connecting quadrangle. + /// Used for hit-testing a stroke against an other stroke (stroke and point erasing) + /// + /// node + /// quadrangle connecting the node to the preceeding node + /// contour segments enumerator + internal virtual IEnumerable GetContourSegments(StrokeNodeData node, Quad quad) + { + System.Diagnostics.Debug.Assert(node.IsEmpty == false); + + if (quad.IsEmpty) + { + Point vertex = node.Position + (_vertices[_vertices.Length - 1] * node.PressureFactor); + for (int i = 0; i < _vertices.Length; i++) + { + Point nextVertex = node.Position + (_vertices[i] * node.PressureFactor); + yield return new ContourSegment(vertex, nextVertex); + vertex = nextVertex; + } + } + else + { + yield return new ContourSegment(quad.A, quad.B); + + for (int i = 0, count = _vertices.Length; i < count; i++) + { + Point vertex = node.Position + (_vertices[i] * node.PressureFactor); + if (vertex == quad.B) + { + for (int j = 0; (j < count) && (vertex != quad.C); j++) + { + i = (i + 1) % count; + Point nextVertex = node.Position + (_vertices[i] * node.PressureFactor); + yield return new ContourSegment(vertex, nextVertex); + vertex = nextVertex; + } + break; + } + } + + yield return new ContourSegment(quad.C, quad.D); + yield return new ContourSegment(quad.D, quad.A); + } + } + + /// + /// ISSUE-2004/06/15- temporary workaround to avoid hit-testing ellipses with ellipses + /// + /// + /// + /// + internal virtual IEnumerable GetNonBezierContourSegments(StrokeNodeData beginNode, StrokeNodeData endNode) + { + Quad quad = beginNode.IsEmpty ? Quad.Empty : GetConnectingQuad(beginNode, endNode); + return GetContourSegments(endNode, quad); + } + + + /// + /// Finds connecting points for a pair of stroke nodes (of a polygonal shape) + /// + /// a node to connect + /// another node, next to beginNode + /// connecting quadrangle, that can be empty if one node is inside the other + internal virtual Quad GetConnectingQuad(in StrokeNodeData beginNode, in StrokeNodeData endNode) + { + // Return an empty quad if either of the nodes is empty (not a node) + // or if both nodes are at the same position. + if (beginNode.IsEmpty || endNode.IsEmpty || DoubleUtil.AreClose(beginNode.Position, endNode.Position)) + { + return Quad.Empty; + } + + // By definition, Quad's vertices (A,B,C,D) are ordered clockwise with points A and D located + // on the beginNode and B and C on the endNode. Basically, we're looking for segments AB and CD. + // We iterate through the vertices of the beginNode, at each vertex we analyze location of the + // connecting segment relative to the node's edges at the vertex, and enforce these rules: + // - if the vector of the connecting segment at a vertex V[i] is on the left from vector V[i]V[i+1] + // and not on the left from vector V[i-1]V[i], then it's the AB segment of the quad (V[i] == A). + // - if the vector of the connecting segment at a vertex V[i] is on the left from vector V[i-1]V[i] + // and not on the left from vector V[i]V[i+1], then it's the CD segment of the quad (V[i] == D). + // + + Quad quad = Quad.Empty; + bool foundAB = false, foundCD = false; + + // There's no need to build shapes of the two nodes in order to find their connecting quad. + // It's the spine vector between the nodes and their scaling diff (pressure delta) is all + // that matters here. + Vector spine = endNode.Position - beginNode.Position; + double pressureDelta = endNode.PressureFactor - beginNode.PressureFactor; + + // Iterate through the vertices of the default shape + int count = _vertices.Length; + for (int i = 0, j = count - 1; i < count; i++, j = ((j + 1) % count)) + { + // Compute vector of the connecting segment at the vertex [i] + Vector connection = spine + _vertices[i] * pressureDelta; + if ((pressureDelta != 0) && (connection.X == 0) && (connection.Y == 0)) + { + // One of the nodes, |----| + // as well as the connecting quad, |__ | + // is entirely inside the other node. | | | + // [i] --> |__|_| + return Quad.Empty; + } + + // Find out where this vector is about the node edge [i][i+1] + // (The vars names "goingTo" and "comingFrom" refer direction of the line defined + // by the connecting vector applied at vertex [i], relative to the contour of the node shape. + // Using these terms, (comingFrom != Right && goingTo == Left) corresponds to the segment AB, + // and (comingFrom == Right && goingTo != Left) describes the DC. + HitResult goingTo = WhereIsVectorAboutVector(connection, _vertices[(i + 1) % count] - _vertices[i]); + + if (goingTo == HitResult.Left) + { + if (false == foundAB) + { + // Find out where the node edge [i-1][i] is about the connecting vector + HitResult comingFrom = WhereIsVectorAboutVector(_vertices[i] - _vertices[j], connection); + if (HitResult.Right != comingFrom) + { + foundAB = true; + quad.A = beginNode.Position + _vertices[i] * beginNode.PressureFactor; + quad.B = endNode.Position + _vertices[i] * endNode.PressureFactor; + if (true == foundCD) + { + // Found all 4 points. Break out from the 'for' loop. + break; + } + } + } + } + else + { + if (false == foundCD) + { + // Find out where the node edge [i-1][i] is about the connecting vector + HitResult comingFrom = WhereIsVectorAboutVector(_vertices[i] - _vertices[j], connection); + if (HitResult.Right == comingFrom) + { + foundCD = true; + quad.C = endNode.Position + _vertices[i] * endNode.PressureFactor; + quad.D = beginNode.Position + _vertices[i] * beginNode.PressureFactor; + if (true == foundAB) + { + // Found all 4 points. Break out from the 'for' loop. + break; + } + } + } + } + } + + if (!foundAB || !foundCD || // (2) + ((pressureDelta != 0) && Vector.Determinant(quad.B - quad.A, quad.D - quad.A) == 0)) // (1) + { + // _____ _______ + // One of the nodes, (1) |__ | (2) | ___ | + // as well as the connecting quad, | | | | | | | + // is entirely inside the other node. |__| | | |__| | + // |____| |___ __| + return Quad.Empty; + } + + return quad; + } + + /// + /// Hit-tests ink segment defined by two nodes against a linear segment. + /// + /// Begin node of the ink segment + /// End node of the ink segment + /// Pre-computed quadrangle connecting the two ink nodes + /// Begin point of the hitting segment + /// End point of the hitting segment + /// true if there's intersection, false otherwise + internal virtual bool HitTest( + in StrokeNodeData beginNode, in StrokeNodeData endNode, Quad quad, Point hitBeginPoint, Point hitEndPoint) + { + // Check for special cases when the endNode is the very first one (beginNode.IsEmpty) + // or one node is completely inside the other. In either case the connecting quad + // would be Empty and we need to hit-test against the biggest node (the one with + // the greater PressureFactor) + if (quad.IsEmpty) + { + Point position; + double pressureFactor; + if (beginNode.IsEmpty || (endNode.PressureFactor > beginNode.PressureFactor)) + { + position = endNode.Position; + pressureFactor = endNode.PressureFactor; + } + else + { + position = beginNode.Position; + pressureFactor = beginNode.PressureFactor; + } + + // Find the coordinates of the hitting segment relative to the ink node + Vector hitBegin = hitBeginPoint - position, hitEnd = hitEndPoint - position; + if (pressureFactor != 1) + { + // Instead of applying pressure to the node, do reverse scaling on + // the hitting segment. This allows us use the original array of vertices + // in hit-testing. + System.Diagnostics.Debug.Assert(DoubleUtil.IsZero(pressureFactor) == false); + hitBegin /= pressureFactor; + hitEnd /= pressureFactor; + } + return HitTestPolygonSegment(_vertices, hitBegin, hitEnd); + } + else + { + // Iterate through the vertices of the contour of the ink segment + // check where the hitting segment is about them, return false if it's + // on the outer (left) side of the ink contour. This implementation might + // look more complex than straightforward separated hit-testing of three + // polygons (beginNode, quad, endNode), but it's supposed to be more optimal + // because the number of edges it hit-tests is approximately twice less + // than with the straightforward implementation. + + // Start with the segment quad.C->quad.D + Vector hitBegin = hitBeginPoint - beginNode.Position; + Vector hitEnd = hitEndPoint - beginNode.Position; + HitResult hitResult = WhereIsSegmentAboutSegment( + hitBegin, hitEnd, quad.C - beginNode.Position, quad.D - beginNode.Position); + if (HitResult.Left == hitResult) + { + return false; + } + + // Continue clockwise from quad.D to quad.C + + HitResult firstResult = hitResult, lastResult = hitResult; + double pressureFactor = beginNode.PressureFactor; + + // Find the index of the vertex that is quad.D + // Use count var to avoid infinite loop, normally it shouldn't + // happen but it doesn't hurt to check it just in case. + int i = 0, count = _vertices.Length; + Vector vertex = new Vector(); + for (i = 0; i < count; i++) + { + vertex = _vertices[i] * pressureFactor; + // Here and in a few more places down the code, when comparing + // a quad's vertex vs a scaled shape vertex, it's important to + // compute them the same way as in GetConnectingQuad, so that not + // hit that double's computation error. For instance, sometimes the + // expression (vertex == quad.D - beginNode.Position) gives 'false' + // while the expression below gives 'true'. (Another workaround is to + // use DoubleUtil.AreClose but that;d be less performant) + if ((beginNode.Position + vertex) == quad.D) + { + break; + } + } + System.Diagnostics.Debug.Assert(count > 0); + // This loop does the iteration thru the edges of the ink segment + // clockwise from quad.D to quad.C. + for (int node = 0; node < 2; node++) + { + Point nodePosition = (node == 0) ? beginNode.Position : endNode.Position; + Point end = (node == 0) ? quad.A : quad.C; + + count = _vertices.Length; + while (((nodePosition + vertex) != end) && (count != 0)) + { + i = (i + 1) % _vertices.Length; + Vector nextVertex = (pressureFactor == 1) ? _vertices[i] : (_vertices[i] * pressureFactor); + hitResult = WhereIsSegmentAboutSegment(hitBegin, hitEnd, vertex, nextVertex); + if (HitResult.Hit == hitResult) + { + return true; + } + if (true == IsOutside(hitResult, lastResult)) + { + return false; + } + lastResult = hitResult; + vertex = nextVertex; + count--; + } + System.Diagnostics.Debug.Assert(count > 0); + + if (node == 0) + { + // The first iteration is done thru the outer segments of beginNode + // and ends at quad.A, for the second one make some adjustments + // to continue iterating through quad.AB and the outer segments of + // endNode up to quad.C + pressureFactor = endNode.PressureFactor; + + Vector spineVector = endNode.Position - beginNode.Position; + vertex -= spineVector; + hitBegin -= spineVector; + hitEnd -= spineVector; + + // Find the index of the vertex that is quad.B + count = _vertices.Length; + while (((endNode.Position + _vertices[i] * pressureFactor) != quad.B) && (count != 0)) + { + i = (i + 1) % _vertices.Length; + count--; + } + System.Diagnostics.Debug.Assert(count > 0); + i--; + } + } + return (false == IsOutside(firstResult, hitResult)); + } + } + + /// + /// Hit-tests a stroke segment defined by two nodes against another stroke segment. + /// + /// Begin node of the stroke segment to hit-test. Can be empty (none) + /// End node of the stroke segment + /// Pre-computed quadrangle connecting the two nodes. + /// Can be empty if the begion node is empty or when one node is entirely inside the other + /// a collection of basic segments outlining the hitting contour + /// true if the contours intersect or overlap + internal virtual bool HitTest( + in StrokeNodeData beginNode, in StrokeNodeData endNode, Quad quad, IEnumerable hitContour) + { + // Check for special cases when the endNode is the very first one (beginNode.IsEmpty) + // or one node is completely inside the other. In either case the connecting quad + // would be Empty and we need to hittest against the biggest node (the one with + // the greater PressureFactor) + if (quad.IsEmpty) + { + // Make a call to hit-test the biggest node the hitting contour. + return HitTestPolygonContourSegments(hitContour, beginNode, endNode); + } + else + { + // HitTest the the hitting contour against the inking contour + return HitTestInkContour(hitContour, quad, beginNode, endNode); + } + } + + /// + /// Hit-tests ink segment defined by two nodes against a linear segment. + /// + /// Begin node of the ink segment + /// End node of the ink segment + /// Pre-computed quadrangle connecting the two ink nodes + /// Begin point of the hitting segment + /// End point of the hitting segment + /// Exact location to cut at represented by StrokeFIndices + internal virtual StrokeFIndices CutTest( + in StrokeNodeData beginNode, in StrokeNodeData endNode, Quad quad, Point hitBeginPoint, Point hitEndPoint) + { + StrokeFIndices result = StrokeFIndices.Empty; + + // First, find out if the hitting segment intersects with either of the ink nodes + for (int node = (beginNode.IsEmpty ? 1 : 0); node < 2; node++) + { + Point position = (node == 0) ? beginNode.Position : endNode.Position; + double pressureFactor = (node == 0) ? beginNode.PressureFactor : endNode.PressureFactor; + + // Adjust the segment for the node's pressure factor + Vector hitBegin = hitBeginPoint - position; + Vector hitEnd = hitEndPoint - position; + if (pressureFactor != 1) + { + System.Diagnostics.Debug.Assert(DoubleUtil.IsZero(pressureFactor) == false); + hitBegin /= pressureFactor; + hitEnd /= pressureFactor; + } + // Hit-test the node against the segment + if (true == HitTestPolygonSegment(_vertices, hitBegin, hitEnd)) + { + if (node == 0) + { + result.BeginFIndex = StrokeFIndices.BeforeFirst; + result.EndFIndex = 0; + } + else + { + result.EndFIndex = StrokeFIndices.AfterLast; + if (beginNode.IsEmpty) + { + result.BeginFIndex = StrokeFIndices.BeforeFirst; + } + else if (result.BeginFIndex != StrokeFIndices.BeforeFirst) + { + result.BeginFIndex = 1; + } + } + } + } + + // If both nodes are hit, return. + if (result.IsFull) + { + return result; + } + // If there's no hit at all, return. + if (result.IsEmpty && (quad.IsEmpty || !HitTestQuadSegment(quad, hitBeginPoint, hitEndPoint))) + { + return result; + } + + // The segments do intersect. Find findices on the ink segment to cut it at. + if (result.BeginFIndex != StrokeFIndices.BeforeFirst) + { + // The begin node is not hit, i.e. the begin findex is on this spine segment, find it. + result.BeginFIndex = ClipTest( + (endNode.Position - beginNode.Position) / beginNode.PressureFactor, + (endNode.PressureFactor / beginNode.PressureFactor) - 1, + (hitBeginPoint - beginNode.Position) / beginNode.PressureFactor, + (hitEndPoint - beginNode.Position) / beginNode.PressureFactor); + } + + if (result.EndFIndex != StrokeFIndices.AfterLast) + { + // The end node is not hit, i.e. the end findex is on this spine segment, find it. + result.EndFIndex = 1 - ClipTest( + (beginNode.Position - endNode.Position) / endNode.PressureFactor, + (beginNode.PressureFactor / endNode.PressureFactor) - 1, + (hitBeginPoint - endNode.Position) / endNode.PressureFactor, + (hitEndPoint - endNode.Position) / endNode.PressureFactor); + } + + if (IsInvalidCutTestResult(result)) + { + return StrokeFIndices.Empty; + } + + return result; + } + + /// + /// CutTest + /// + /// Begin node of the stroke segment to hit-test. Can be empty (none) + /// End node of the stroke segment + /// Pre-computed quadrangle connecting the two nodes. + /// Can be empty if the begion node is empty or when one node is entirely inside the other + /// a collection of basic segments outlining the hitting contour + /// + internal virtual StrokeFIndices CutTest( + in StrokeNodeData beginNode, in StrokeNodeData endNode, Quad quad, IEnumerable hitContour) + { + if (beginNode.IsEmpty) + { + if (HitTest(beginNode, endNode, quad, hitContour) == true) + { + return StrokeFIndices.Full; + } + return StrokeFIndices.Empty; + } + + StrokeFIndices result = StrokeFIndices.Empty; + bool isInside = true; + Vector spineVector = (endNode.Position - beginNode.Position) / beginNode.PressureFactor; + Vector spineVectorReversed = (beginNode.Position - endNode.Position) / endNode.PressureFactor; + double pressureDelta = (endNode.PressureFactor / beginNode.PressureFactor) - 1; + double pressureDeltaReversed = (beginNode.PressureFactor / endNode.PressureFactor) - 1; + + foreach (ContourSegment hitSegment in hitContour) + { + + // First, find out if hitSegment intersects with either of the ink nodes + bool isHit = HitTestStrokeNodes(hitSegment, beginNode, endNode, ref result); + + // If both nodes are hit, return. + if (result.IsFull) + { + return result; + } + + // If neither of the nodes is hit, hit-test the connecting quad + if (isHit == false) + { + // If neither of the nodes is hit and the contour of one node is entirely + // inside the contour of the other node, then done with this hitting segment + if (!quad.IsEmpty) + { + isHit = hitSegment.IsArc + ? HitTestQuadCircle(quad, hitSegment.Begin + hitSegment.Radius, hitSegment.Radius) + : HitTestQuadSegment(quad, hitSegment.Begin, hitSegment.End); + } + + if (isHit == false) + { + if (isInside == true) + { + isInside = hitSegment.IsArc + ? (WhereIsVectorAboutArc(endNode.Position - hitSegment.Begin - hitSegment.Radius, + -hitSegment.Radius, hitSegment.Vector - hitSegment.Radius) != HitResult.Hit) + : (WhereIsVectorAboutVector( + endNode.Position - hitSegment.Begin, hitSegment.Vector) == HitResult.Right); + } + continue; + } + } + + isInside = false; + + // If the begin node is not hit, find the begin findex on the ink segment to cut it at + if (!DoubleUtil.AreClose(result.BeginFIndex, StrokeFIndices.BeforeFirst)) + { + double findex = CalculateClipLocation(hitSegment, beginNode, spineVector, pressureDelta); + if (findex != StrokeFIndices.BeforeFirst) + { + System.Diagnostics.Debug.Assert(findex >= 0 && findex <= 1); + if (result.BeginFIndex > findex) + { + result.BeginFIndex = findex; + } + } + } + + // If the end node is not hit, find the end findex on the ink segment to cut it at + if (!DoubleUtil.AreClose(result.EndFIndex, StrokeFIndices.AfterLast)) + { + double findex = CalculateClipLocation(hitSegment, endNode, spineVectorReversed, pressureDeltaReversed); + if (findex != StrokeFIndices.BeforeFirst) + { + System.Diagnostics.Debug.Assert(findex >= 0 && findex <= 1); + findex = 1 - findex; + if (result.EndFIndex < findex) + { + result.EndFIndex = findex; + } + } + } + } + + if (DoubleUtil.AreClose(result.BeginFIndex, StrokeFIndices.AfterLast)) + { + if (!DoubleUtil.AreClose(result.EndFIndex, StrokeFIndices.BeforeFirst)) + { + result.BeginFIndex = StrokeFIndices.BeforeFirst; + } + } + else if (DoubleUtil.AreClose(result.EndFIndex, StrokeFIndices.BeforeFirst)) + { + result.EndFIndex = StrokeFIndices.AfterLast; + } + + if (IsInvalidCutTestResult(result)) + { + return StrokeFIndices.Empty; + } + + return (result.IsEmpty && isInside) ? StrokeFIndices.Full : result; + } + + /// + /// Cutting ink with polygonal tip shapes with a linear segment + /// + /// Vector representing the starting and ending point for the inking + /// segment + /// Represents the difference in the node size for startNode and endNode. + /// pressureDelta = (endNode.PressureFactor / beginNode.PressureFactor) - 1 + /// Start point of the hitting segment + /// End point of the hitting segment + /// a double representing the point of clipping + private double ClipTest(Vector spineVector, double pressureDelta, Vector hitBegin, Vector hitEnd) + { + // Let's represent the vertices for the startNode are N1, N2, ..., Ni and for the endNode, M1, M2, + // ..., Mi. + // When ink tip shape is a convex polygon, one may iterate in a constant direction + // (for instance, clockwise) through the edges of the polygon P1 and hit test the cutting segment + // against quadrangles NIMIMI+1NI+1 with MI on the left side off the vector NINI+1. + // If the cutting segment intersects the quadrangle, on the intersected part of the segment, + // one may then find point Q (the nearest to the line NINI+1) and point QP + // (the point of the intersection of the segment NIMI and vector NI+1NI started at Q). + // Next, + // QP = NI + s * LengthOf(MI - NI) (1) + // s = LengthOf(QP - NI ) / LengthOf(MI - NI). (2) + // If the cutting segment intersects more than one quadrant, one may then use the smallest s + // to find the split point: + // S = P1 + s * LengthOf(P2 - P1) (3) + double findex = StrokeFIndices.AfterLast; + Vector hitVector = hitEnd - hitBegin; + Vector lastVertex = _vertices[_vertices.Length - 1]; + + // Note the definition of pressureDelta = (endNode.PressureFactor / beginNode.PressureFactor) - 1 + // So the equation below gives + // nextNode = spineVector + (endNode.PressureFactor / beginNode.PressureFactor)*lastVertex - lastVertex + // As a result, nextNode is a Vector pointing from lastVertex of the beginNode to the correspoinding "lastVertex" + // of the endNode. + Vector nextNode = spineVector + lastVertex * pressureDelta; + bool testNextEdge = false; + + for (int k = 0, count = _vertices.Length; k < count || (k == count && testNextEdge); k++) + { + Vector vertex = _vertices[k % count]; + Vector nextVertex = vertex - lastVertex; + + // Point from vertex in beginNode to the corresponding "vertex" in endNode + Vector nextVertexNextNode = spineVector + (vertex * pressureDelta); + + // Find out a "nextNode" on the endNode (nextNode) that is on the left side off the vector + // (lastVertex, vertex). + if ((DoubleUtil.IsZero(nextNode.X) && DoubleUtil.IsZero(nextNode.Y)) || + (!testNextEdge && (HitResult.Left != WhereIsVectorAboutVector(nextNode, nextVertex)))) + { + lastVertex = vertex; + nextNode = nextVertexNextNode; + continue; + } + + // Now we need to do hit testing of the hitting segment against quarangle (NI, MI, MI+1, NI+1), + // that is, (lastVertex, nextNode, nextVertexNextNode, vertex) + + testNextEdge = false; + HitResult hit = HitResult.Left; + int side = 0; + for (int i = 0; i < 2; i++) + { + Vector hitPoint = ((0 == i) ? hitBegin : hitEnd) - lastVertex; + + hit = WhereIsVectorAboutVector(hitPoint, nextNode); + if (hit == HitResult.Hit) + { + double r = (Math.Abs(nextNode.X) < Math.Abs(nextNode.Y)) //DoubleUtil.IsZero(nextNode.X) + ? (hitPoint.Y / nextNode.Y) + : (hitPoint.X / nextNode.X); + if ((findex > r) && DoubleUtil.IsBetweenZeroAndOne(r)) + { + findex = r; + } + } + else if (hit == HitResult.Right) + { + side++; + if (HitResult.Left == WhereIsVectorAboutVector( + hitPoint - nextVertex, nextVertexNextNode)) + { + double r = GetPositionBetweenLines(nextVertex, nextNode, hitPoint); + if ((findex > r) && DoubleUtil.IsBetweenZeroAndOne(r)) + { + findex = r; + } + } + else + { + testNextEdge = true; + } + } + else + { + side--; + } + } + + // + if (0 == side) + { + if (hit == HitResult.Hit) + { + // This segment is collinear with the edge connecting the nodes, + // no need to hit-test the other edges. + System.Diagnostics.Debug.Assert(true == DoubleUtil.IsBetweenZeroAndOne(findex)); + break; + } + // The hitting segment intersects the line of the edge connecting + // the nodes. Find the findex of the intersection point. + double det = -Vector.Determinant(nextNode, hitVector); + if (DoubleUtil.IsZero(det) == false) + { + double s = Vector.Determinant(hitVector, hitBegin - lastVertex) / det; + if ((findex > s) && DoubleUtil.IsBetweenZeroAndOne(s)) + { + findex = s; + } + } + } + // + lastVertex = vertex; + nextNode = nextVertexNextNode; + } + return AdjustFIndex(findex); + } + + /// + /// Clip-Testing a polygonal inking segment against an arc (circle) + /// + /// Vector representing the starting and ending point for the inking + /// segment + /// Represents the difference in the node size for startNode and endNode. + /// pressureDelta = (endNode.PressureFactor / beginNode.PressureFactor) - 1 + /// The center of the hitting circle + /// The radius of the hitting circle + /// a double representing the point of clipping + private double ClipTestArc(Vector spineVector, double pressureDelta, Vector hitCenter, Vector hitRadius) + { + // this code is not called, but will be in VNext + throw new NotImplementedException(); + /* + double findex = StrokeFIndices.AfterLast; + + double radiusSquared = hitRadius.LengthSquared; + Vector vertex, lastVertex = _vertices[_vertices.Length - 1]; + Vector nextVertexNextNode, nextNode = spineVector + lastVertex * pressureDelta; + bool testNextEdge = false; + + for (int k = 0, count = _vertices.Length; + k < count || (k == count && testNextEdge); + k++, lastVertex = vertex, nextNode = nextVertexNextNode) + { + vertex = _vertices[k % count]; + Vector nextVertex = vertex - lastVertex; + nextVertexNextNode = spineVector + (vertex * pressureDelta); + + if (DoubleUtil.IsZero(nextNode.X) && DoubleUtil.IsZero(nextNode.Y)) + { + continue; + } + + bool testConnectingEdge = false; + + if (HitResult.Left == WhereIsVectorAboutVector(nextNode, nextVertex)) + { + testNextEdge = false; + + Vector normal = GetProjection(lastVertex - hitCenter, vertex - hitCenter); + if (radiusSquared <= normal.LengthSquared) + { + if (WhereIsVectorAboutVector(hitCenter - lastVertex, nextVertex) == HitResult.Left) + { + Vector hitPoint = hitCenter + (normal * Math.Sqrt(radiusSquared / normal.LengthSquared)); + if (HitResult.Right == WhereIsVectorAboutVector(hitPoint - vertex, nextVertexNextNode)) + { + testNextEdge = true; + } + else if (HitResult.Left == WhereIsVectorAboutVector(hitPoint - lastVertex, nextNode)) + { + testConnectingEdge = true; + } + else + { + // this is it + findex = GetPositionBetweenLines(nextVertex, nextNode, hitPoint - lastVertex); + System.Diagnostics.Debug.Assert(DoubleUtil.IsBetweenZeroAndOne(findex)); + break; + } + } + } + else if (HitResult.Right == WhereIsVectorAboutVector(hitCenter + normal - lastVertex, nextNode)) + { + testNextEdge = true; + } + else + { + testConnectingEdge = true; + } + } + else if (testNextEdge == true) + { + testNextEdge = false; + testConnectingEdge = true; + } + + if (testConnectingEdge) + { + // Find out the projection of hitCenter on nextNode + Vector v = lastVertex - hitCenter; + double findexNearest = GetProjectionFIndex(v, v + nextNode); + + if (findexNearest > 0) + { + Vector nearest = nextNode * findexNearest; + double squaredDistanceFromNearestToHitPoint = radiusSquared - (nearest + v).LengthSquared; + if (DoubleUtil.IsZero(squaredDistanceFromNearestToHitPoint) && (findexNearest <= 1)) + { + if (findexNearest < findex) + { + findex = findexNearest; + } + } + else if ((squaredDistanceFromNearestToHitPoint > 0) + && (nearest.LengthSquared >= squaredDistanceFromNearestToHitPoint)) + { + double hitPointFIndex = findexNearest - Math.Sqrt( + squaredDistanceFromNearestToHitPoint / nextNode.LengthSquared); + System.Diagnostics.Debug.Assert(DoubleUtil.GreaterThanOrClose(hitPointFIndex, 0)); + if (hitPointFIndex < findex) + { + findex = hitPointFIndex; + } + } + } + } + } + + return AdjustFIndex(findex); + */ + } + + /// + /// Internal access to __vertices + /// + /// + internal Vector[] GetVertices() + { + return _vertices; + } + + /// + /// Helper function to hit-test the biggest node against hitting contour segments + /// + /// a collection of basic segments outlining the hitting contour + /// Begin node of the stroke segment to hit-test. Can be empty (none) + /// End node of the stroke segment + /// true if hit; false otherwise + private bool HitTestPolygonContourSegments( + IEnumerable hitContour, in StrokeNodeData beginNode, in StrokeNodeData endNode) + { + bool isHit = false; + + // The bool variable isInside is used here to track that case. It answers to + // 'Is ink contour inside if the hitting contour?'. It's initialized to 'true" + // and then verified for each edge of the hitting contour until there's a hit or + // until it's false. + bool isInside = true; + + Point position; + double pressureFactor; + if (beginNode.IsEmpty || endNode.PressureFactor > beginNode.PressureFactor) + { + position = endNode.Position; + pressureFactor = endNode.PressureFactor; + } + else + { + position = beginNode.Position; + pressureFactor = beginNode.PressureFactor; + } + + // Enumerate through the segments of the hitting contour and test them + // one by one against the contour of the ink node. + foreach (ContourSegment hitSegment in hitContour) + { + if (hitSegment.IsArc) + { + // Adjust the arc for the node' pressure factor. + Vector hitCenter = hitSegment.Begin + hitSegment.Radius - position; + Vector hitRadius = hitSegment.Radius; + if (!DoubleUtil.AreClose(pressureFactor, 1d)) + { + System.Diagnostics.Debug.Assert(DoubleUtil.IsZero(pressureFactor) == false); + hitCenter /= pressureFactor; + hitRadius /= pressureFactor; + } + // If the segment is an arc, hit-test against the entire circle the arc is part of. + if (true == HitTestPolygonCircle(_vertices, hitCenter, hitRadius)) + { + isHit = true; + break; + } + // + if (isInside && (WhereIsVectorAboutArc( + position - hitSegment.Begin - hitSegment.Radius, + -hitSegment.Radius, hitSegment.Vector - hitSegment.Radius) == HitResult.Hit)) + { + isInside = false; + } + } + else + { + // Adjust the segment for the node's pressure factor + Vector hitBegin = hitSegment.Begin - position; + Vector hitEnd = hitBegin + hitSegment.Vector; + if (!DoubleUtil.AreClose(pressureFactor, 1d)) + { + System.Diagnostics.Debug.Assert(DoubleUtil.IsZero(pressureFactor) == false); + hitBegin /= pressureFactor; + hitEnd /= pressureFactor; + } + // Hit-test the node against the segment + if (true == HitTestPolygonSegment(_vertices, hitBegin, hitEnd)) + { + isHit = true; + break; + } + // + if (isInside && WhereIsVectorAboutVector( + position - hitSegment.Begin, hitSegment.Vector) != HitResult.Right) + { + isInside = false; + } + } + } + return (isInside || isHit); + } + + /// + /// Helper function to HitTest the the hitting contour against the inking contour + /// + /// a collection of basic segments outlining the hitting contour + /// A connecting quad + /// Begin node of the stroke segment to hit-test. Can be empty (none) + /// End node of the stroke segment + /// true if hit; false otherwise + private bool HitTestInkContour( + IEnumerable hitContour, Quad quad, in StrokeNodeData beginNode, in StrokeNodeData endNode) + { + System.Diagnostics.Debug.Assert(!quad.IsEmpty); + bool isHit = false; + + // When hit-testing a contour against another contour, like in this case, + // the default implementation checks whether any edge (segment) of the hitting + // contour intersects with the contour of the ink segment. But this doesn't cover + // the case when the ink segment is entirely inside of the hitting segment. + // The bool variable isInside is used here to track that case. It answers to + // 'Is ink contour inside if the hitting contour?'. It's initialized to 'true" + // and then verified for each edge of the hitting contour until there's a hit or + // until it's false. + bool isInside = true; + + // The ink connecting quad is not empty, enumerate through the segments of the + // hitting contour and hit-test them one by one against the ink contour. + foreach (ContourSegment hitSegment in hitContour) + { + // Iterate through the vertices of the contour of the ink segment + // check where the hit segment is about them, return false if it's + // on the left side off either of the ink contour segments. + + Vector hitBegin, hitEnd; + HitResult hitResult; + + // Start with the segment quad.C->quad.D + if (hitSegment.IsArc) + { + hitBegin = hitSegment.Begin + hitSegment.Radius - beginNode.Position; + hitEnd = hitSegment.Radius; + hitResult = WhereIsCircleAboutSegment( + hitBegin, hitEnd, quad.C - beginNode.Position, quad.D - beginNode.Position); + } + else + { + hitBegin = hitSegment.Begin - beginNode.Position; + hitEnd = hitBegin + hitSegment.Vector; + hitResult = WhereIsSegmentAboutSegment( + hitBegin, hitEnd, quad.C - beginNode.Position, quad.D - beginNode.Position); + } + if (HitResult.Left == hitResult) + { + if (isInside) + { + isInside = hitSegment.IsArc + ? (WhereIsVectorAboutArc(-hitBegin, -hitSegment.Radius, hitSegment.Vector - hitSegment.Radius) != HitResult.Hit) + : (WhereIsVectorAboutVector(-hitBegin, hitSegment.Vector) == HitResult.Right); + } + // This hitSegment is completely outside of the ink contour, + // continue with the next one. + continue; + } + + // Continue clockwise from quad.D to quad.A, then to quad.B, ..., quad.C + + HitResult firstResult = hitResult, lastResult = hitResult; + double pressureFactor = beginNode.PressureFactor; + + // Find the index of the vertex that is quad.D + // Use count var to avoid infinite loop, normally this shouldn't + // happen but it doesn't hurt to check it just in case. + int i = 0, count = _vertices.Length; + Vector vertex = new Vector(); + for (i = 0; i < count; i++) + { + vertex = _vertices[i] * pressureFactor; + if (DoubleUtil.AreClose((beginNode.Position + vertex), quad.D)) + { + break; + } + } + System.Diagnostics.Debug.Assert(i < count); + + int k; + for (k = 0; k < 2; k++) + { + count = _vertices.Length; + Point nodePosition = (k == 0) ? beginNode.Position : endNode.Position; + Point end = (k == 0) ? quad.A : quad.C; + + // Iterate over the vertices on + // beginNode(k=0)from quad.D to quad.A + // or + // endNode(k=1)from quad.A to quad.B ... to quad.C + while (((nodePosition + vertex) != end) && (count != 0)) + { + // Find out the next vertex + i = (i + 1) % _vertices.Length; + Vector nextVertex = _vertices[i] * pressureFactor; + + // Hit-test the hitting segment against the current edge + hitResult = hitSegment.IsArc + ? WhereIsCircleAboutSegment(hitBegin, hitEnd, vertex, nextVertex) + : WhereIsSegmentAboutSegment(hitBegin, hitEnd, vertex, nextVertex); + + if (HitResult.Hit == hitResult) + { + return true; //Got a hit + } + if (true == IsOutside(hitResult, lastResult)) + { + // This hitSegment is definitely outside the ink contour, drop it. + // Change k to something > 2 to leave the for loop and skip + // IsOutside at the bottom + k = 3; + break; + } + lastResult = hitResult; + vertex = nextVertex; + count--; + } + System.Diagnostics.Debug.Assert(count > 0); + + if (k == 0) + { + // Make some adjustments for the second one to continue iterating through + // quad.AB and the outer segments of endNode up to quad.C + pressureFactor = endNode.PressureFactor; + Vector spineVector = endNode.Position - beginNode.Position; + vertex -= spineVector; // now vertex = quad.A - spineVector + hitBegin -= spineVector; // adjust hitBegin to the space of endNode + if (hitSegment.IsArc == false) + { + hitEnd -= spineVector; + } + + // Find the index of the vertex that is quad.B + count = _vertices.Length; + while (!DoubleUtil.AreClose((endNode.Position + _vertices[i] * pressureFactor), quad.B) && (count != 0)) + { + i = (i + 1) % _vertices.Length; + count--; + } + System.Diagnostics.Debug.Assert(count > 0); + i--; + } + } + if ((k == 2) && (false == IsOutside(firstResult, hitResult))) + { + isHit = true; + break; + } + // + if (isInside) + { + isInside = hitSegment.IsArc + ? (WhereIsVectorAboutArc(-hitBegin, -hitSegment.Radius, hitSegment.Vector - hitSegment.Radius) != HitResult.Hit) + : (WhereIsVectorAboutVector(-hitBegin, hitSegment.Vector) == HitResult.Right); + } + } + return (isHit || isInside); + } + + + /// + /// Helper function to Hit-test against the two stroke nodes only (excluding the connecting quad). + /// + /// + /// + /// + /// + /// + private bool HitTestStrokeNodes( + in ContourSegment hitSegment, in StrokeNodeData beginNode, in StrokeNodeData endNode, ref StrokeFIndices result) + { + // First, find out if hitSegment intersects with either of the ink nodes + bool isHit = false; + for (int node = 0; node < 2; node++) + { + Point position; + double pressureFactor; + if (node == 0) + { + if (isHit && DoubleUtil.AreClose(result.BeginFIndex, StrokeFIndices.BeforeFirst)) + { + continue; + } + position = beginNode.Position; + pressureFactor = beginNode.PressureFactor; + } + else + { + if (isHit && DoubleUtil.AreClose(result.EndFIndex, StrokeFIndices.AfterLast)) + { + continue; + } + position = endNode.Position; + pressureFactor = endNode.PressureFactor; + } + + Vector hitBegin, hitEnd; + + // Adjust the segment for the node's pressure factor + if (hitSegment.IsArc) + { + hitBegin = hitSegment.Begin - position + hitSegment.Radius; + hitEnd = hitSegment.Radius; + } + else + { + hitBegin = hitSegment.Begin - position; + hitEnd = hitBegin + hitSegment.Vector; + } + + if (pressureFactor != 1) + { + System.Diagnostics.Debug.Assert(DoubleUtil.IsZero(pressureFactor) == false); + hitBegin /= pressureFactor; + hitEnd /= pressureFactor; + } + // Hit-test the node against the segment + if (hitSegment.IsArc + ? HitTestPolygonCircle(_vertices, hitBegin, hitEnd) + : HitTestPolygonSegment(_vertices, hitBegin, hitEnd)) + { + isHit = true; + if (node == 0) + { + result.BeginFIndex = StrokeFIndices.BeforeFirst; + if (DoubleUtil.AreClose(result.EndFIndex, StrokeFIndices.AfterLast)) + { + break; + } + } + else + { + result.EndFIndex = StrokeFIndices.AfterLast; + if (beginNode.IsEmpty) + { + result.BeginFIndex = StrokeFIndices.BeforeFirst; + break; + } + if (DoubleUtil.AreClose(result.BeginFIndex, StrokeFIndices.BeforeFirst)) + { + break; + } + } + } + } + return isHit; + } + + /// + /// Calculate the clip location + /// + /// the hitting segment + /// begin node + /// + /// + /// the clip location. not-clip if return StrokeFIndices.BeforeFirst + private double CalculateClipLocation( + in ContourSegment hitSegment, in StrokeNodeData beginNode, Vector spineVector, double pressureDelta) + { + double findex = StrokeFIndices.BeforeFirst; + bool clipIt = hitSegment.IsArc ? true + //? (WhereIsVectorAboutArc(beginNode.Position - hitSegment.Begin - hitSegment.Radius, + // -hitSegment.Radius, hitSegment.Vector - hitSegment.Radius) == HitResult.Hit) + : (WhereIsVectorAboutVector( + beginNode.Position - hitSegment.Begin, hitSegment.Vector) == HitResult.Left); + if (clipIt) + { + findex = hitSegment.IsArc + ? ClipTestArc(spineVector, pressureDelta, + (hitSegment.Begin + hitSegment.Radius - beginNode.Position) / beginNode.PressureFactor, + hitSegment.Radius / beginNode.PressureFactor) + : ClipTest(spineVector, pressureDelta, + (hitSegment.Begin - beginNode.Position) / beginNode.PressureFactor, + (hitSegment.End - beginNode.Position) / beginNode.PressureFactor); + + // ClipTest returns StrokeFIndices.AfterLast to indicate a false hit test. + // But the caller CutTest expects StrokeFIndices.BeforeFirst when there is no hit. + if (findex == StrokeFIndices.AfterLast) + { + findex = StrokeFIndices.BeforeFirst; + } + else + { + System.Diagnostics.Debug.Assert(findex >= 0 && findex <= 1); + } + } + return findex; + } + + /// + /// Helper method used to determine if we came up with a bogus result during hit testing + /// + protected bool IsInvalidCutTestResult(StrokeFIndices result) + { + // + // check for three invalid states + // 1) BeforeFirst == AfterLast + // 2) BeforeFirst, < 0 + // 3) > 1, AfterLast + // + if (DoubleUtil.AreClose(result.BeginFIndex, result.EndFIndex) || + DoubleUtil.AreClose(result.BeginFIndex, StrokeFIndices.BeforeFirst) && result.EndFIndex < 0.0f || + result.BeginFIndex > 1.0f && DoubleUtil.AreClose(result.EndFIndex, StrokeFIndices.AfterLast)) + { + return true; + } + return false; + } + + #endregion + + #region Instance data + + // Shape parameters + private Rect _shapeBounds = Rect.Empty; + protected Vector[] _vertices; + + #endregion + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeNodeOperations2.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeNodeOperations2.cs new file mode 100644 index 0000000..f55a717 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeNodeOperations2.cs @@ -0,0 +1,579 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Input; +using WpfInk.PresentationCore.System.Windows; + +namespace MS.Internal.Ink +{ + /// + /// Static methods implementing generic hit-testing operations + /// + internal partial class StrokeNodeOperations + { + #region enum HitResult + + /// A set of possible results frequently used in StrokeNodeOperations and derived classes + internal enum HitResult + { + Hit, + Left, + Right, + InFront, + Behind + } + + #endregion + + #region HitTestXxxYyy + + /// + /// Hit-tests a linear segment against a convex polygon. + /// + /// Vertices of the polygon (in clockwise order) + /// an end point of the hitting segment + /// an end point of the hitting segment + /// true if hit; false otherwise + internal static bool HitTestPolygonSegment(Vector[] vertices, Vector hitBegin, Vector hitEnd) + { + System.Diagnostics.Debug.Assert((null != vertices) && (2 < vertices.Length)); + + HitResult hitResult = HitResult.Right, firstResult = HitResult.Right, prevResult = HitResult.Right; + int count = vertices.Length; + Vector vertex = vertices[count - 1]; + for (int i = 0; i < count; i++) + { + Vector nextVertex = vertices[i]; + hitResult = WhereIsSegmentAboutSegment(hitBegin, hitEnd, vertex, nextVertex); + if (HitResult.Hit == hitResult) + { + return true; + } + if (IsOutside(hitResult, prevResult)) + { + return false; + } + if (i == 0) + { + firstResult = hitResult; + } + prevResult = hitResult; + vertex = nextVertex; + } + return (false == IsOutside(firstResult, hitResult)); + } + + /// + /// This is a specialized version of HitTestPolygonSegment that takes + /// a Quad for a polygon. This method is called very intensively by + /// hit-testing API and we don't want to create Vector[] for every quad it hit-tests. + /// + /// the connecting quad to test against + /// begin point of the hitting segment + /// end point of the hitting segment + /// true if hit, false otherwise + internal static bool HitTestQuadSegment(Quad quad, Point hitBegin, Point hitEnd) + { + System.Diagnostics.Debug.Assert(quad.IsEmpty == false); + + HitResult hitResult = HitResult.Right, firstResult = HitResult.Right, prevResult = HitResult.Right; + int count = 4; + Vector zeroVector = new Vector(0, 0); + Vector hitVector = hitEnd - hitBegin; + Vector vertex = quad[count - 1] - hitBegin; + + for (int i = 0; i < count; i++) + { + Vector nextVertex = quad[i] - hitBegin; + hitResult = WhereIsSegmentAboutSegment(zeroVector, hitVector, vertex, nextVertex); + if (HitResult.Hit == hitResult) + { + return true; + } + if (true == IsOutside(hitResult, prevResult)) + { + return false; + } + if (i == 0) + { + firstResult = hitResult; + } + prevResult = hitResult; + vertex = nextVertex; + } + return (false == IsOutside(firstResult, hitResult)); + } + + /// + /// Hit-test a polygin against a circle + /// + /// Vectors representing the vertices of the polygon, ordered in clockwise order + /// Vector representing the center of the circle + /// Vector representing the radius of the circle + /// true if hit, false otherwise + internal static bool HitTestPolygonCircle(Vector[] vertices, Vector center, Vector radius) + { + // this code is not called, but will be in VNext + throw new NotImplementedException(); + /* + System.Diagnostics.Debug.Assert((null != vertices) && (2 < vertices.Length)); + + HitResult hitResult = HitResult.Right, firstResult = HitResult.Right, prevResult = HitResult.Right; + int count = vertices.Length; + Vector vertex = vertices[count - 1]; + + for (int i = 0; i < count; i++) + { + Vector nextVertex = vertices[i]; + hitResult = WhereIsCircleAboutSegment(center, radius, vertex, nextVertex); + if (HitResult.Hit == hitResult) + { + return true; + } + if (true == IsOutside(hitResult, prevResult)) + { + return false; + } + if (i == 0) + { + firstResult = hitResult; + } + prevResult = hitResult; + vertex = nextVertex; + } + return (false == IsOutside(firstResult, hitResult)); + */ + } + + /// + /// This is a specialized version of HitTestPolygonCircle that takes + /// a Quad for a polygon. This method is called very intensively by + /// hit-testing API and we don't want to create Vector[] for every quad it hit-tests. + /// + /// the connecting quad + /// center of the circle + /// radius of the circle + /// true if hit; false otherwise + internal static bool HitTestQuadCircle(Quad quad, Point center, Vector radius) + { + // this code is not called, but will be in VNext + throw new NotImplementedException(); + /* + System.Diagnostics.Debug.Assert(quad.IsEmpty == false); + + Vector centerVector = (Vector)center; + HitResult hitResult = HitResult.Right, firstResult = HitResult.Right, prevResult = HitResult.Right; + int count = 4; + Vector vertex = (Vector)quad[count - 1]; + + for (int i = 0; i < count; i++) + { + Vector nextVertex = (Vector)quad[i]; + hitResult = WhereIsCircleAboutSegment(centerVector, radius, vertex, nextVertex); + if (HitResult.Hit == hitResult) + { + return true; + } + if (true == IsOutside(hitResult, prevResult)) + { + return false; + } + if (i == 0) + { + firstResult = hitResult; + } + prevResult = hitResult; + vertex = nextVertex; + } + return (false == IsOutside(firstResult, hitResult)); + */ + } + + #endregion + + #region Whereabouts + + /// + /// Finds out where the segment [hitBegin, hitEnd] + /// is about the segment [orgBegin, orgEnd]. + /// + internal static HitResult WhereIsSegmentAboutSegment( + Vector hitBegin, Vector hitEnd, Vector orgBegin, Vector orgEnd) + { + if (hitEnd == hitBegin) + { + return WhereIsCircleAboutSegment(hitBegin, new Vector(0, 0), orgBegin, orgEnd); + } + + //---------------------------------------------------------------------- + // Source: http://isc.faqs.org/faqs/graphics/algorithms-faq/ + // Subject 1.03: How do I find intersections of 2 2D line segments? + // + // Let A,B,C,D be 2-space position vectors. Then the directed line + // segments AB & CD are given by: + // + // AB=A+r(B-A), r in [0,1] + // CD=C+s(D-C), s in [0,1] + // + // If AB & CD intersect, then + // + // A+r(B-A)=C+s(D-C), or Ax+r(Bx-Ax)=Cx+s(Dx-Cx) + // Ay+r(By-Ay)=Cy+s(Dy-Cy) for some r,s in [0,1] + // + // Solving the above for r and s yields + // + // (Ay-Cy)(Dx-Cx)-(Ax-Cx)(Dy-Cy) + // r = ----------------------------- (eqn 1) + // (Bx-Ax)(Dy-Cy)-(By-Ay)(Dx-Cx) + // + // (Ay-Cy)(Bx-Ax)-(Ax-Cx)(By-Ay) + // s = ----------------------------- (eqn 2) + // (Bx-Ax)(Dy-Cy)-(By-Ay)(Dx-Cx) + // + // Let P be the position vector of the intersection point, then + // + // P=A+r(B-A) or Px=Ax+r(Bx-Ax) and Py=Ay+r(By-Ay) + // + // By examining the values of r & s, you can also determine some + // other limiting conditions: + // If 0 <= r <= 1 && 0 <= s <= 1, intersection exists + // r < 0 or r > 1 or s < 0 or s > 1 line segments do not intersect + // If the denominator in eqn 1 is zero, AB & CD are parallel + // If the numerator in eqn 1 is also zero, AB & CD are collinear. + // If they are collinear, then the segments may be projected to the x- + // or y-axis, and overlap of the projected intervals checked. + // + // If the intersection point of the 2 lines are needed (lines in this + // context mean infinite lines) regardless whether the two line + // segments intersect, then + // If r > 1, P is located on extension of AB + // If r < 0, P is located on extension of BA + // If s > 1, P is located on extension of CD + // If s < 0, P is located on extension of DC + // Also note that the denominators of eqn 1 & 2 are identical. + // + // References: + // [O'Rourke (C)] pp. 249-51 + // [Gems III] pp. 199-202 "Faster Line Segment Intersection," + //---------------------------------------------------------------------- + // The result tells where the segment CD is about the vector AB. + // Return "Right" if either C or D is not on the left from AB. + HitResult result = HitResult.Right; + + // Calculate the vectors. + Vector AB = orgEnd - orgBegin; // B - A + Vector CA = orgBegin - hitBegin; // A - C + Vector CD = hitEnd - hitBegin; // D - C + double det = Vector.Determinant(AB, CD); + + if (DoubleUtil.IsZero(det)) + { + // The segments are parallel. + /*if (DoubleUtil.IsZero(Vector.Determinant(CD, CA))) + { + // The segments are collinear. + // Check if their X and Y projections overlap. + if ((Math.Max(orgBegin.X, orgEnd.X) >= Math.Min(hitBegin.X, hitEnd.X)) && + (Math.Min(orgBegin.X, orgEnd.X) <= Math.Max(hitBegin.X, hitEnd.X)) && + (Math.Max(orgBegin.Y, orgEnd.Y) >= Math.Min(hitBegin.Y, hitEnd.Y)) && + (Math.Min(orgBegin.Y, orgEnd.Y) <= Math.Max(hitBegin.Y, hitEnd.Y))) + { + // The segments overlap. + result = HitResult.Hit; + } + else if (false == DoubleUtil.IsZero(AB.X)) + { + result = ((AB.X * CA.X) > 0) ? HitResult.Behind : HitResult.InFront; + } + else + { + result = ((AB.Y * CA.Y) > 0) ? HitResult.Behind : HitResult.InFront; + } + } + else */ + if (DoubleUtil.IsZero(Vector.Determinant(CD, CA)) || DoubleUtil.GreaterThan(Vector.Determinant(AB, CA), 0)) + { + // C is on the left from AB, and, since the segments are parallel, D is also on the left. + result = HitResult.Left; + } + } + else + { + double r = AdjustFIndex(Vector.Determinant(AB, CA) / det); + + if (r > 0 && r < 1) + { + // The line defined AB does cross the segment CD. + double s = AdjustFIndex(Vector.Determinant(CD, CA) / det); + if (s > 0 && s < 1) + { + // The crossing point is on the segment AB as well. + result = HitResult.Hit; + } + else + { + result = (0 < s) ? HitResult.InFront : HitResult.Behind; + } + } + else if ((WhereIsVectorAboutVector(hitBegin - orgBegin, AB) == HitResult.Left) + || (WhereIsVectorAboutVector(hitEnd - orgBegin, AB) == HitResult.Left)) + { + // The line defined AB doesn't cross the segment CD, and neither C nor D + // is on the right from AB + result = HitResult.Left; + } + } + + return result; + } + + /// + /// Find out the relative location of a circle relative to a line segment + /// + /// center of the circle + /// radius of the circle. center.radius is a point on the circle + /// begin point of the line segment + /// end point of the line segment + /// test result + internal static HitResult WhereIsCircleAboutSegment( + Vector center, Vector radius, Vector segBegin, Vector segEnd) + { + segBegin -= center; + segEnd -= center; + double radiusSquared = radius.LengthSquared; + + // This will find out the nearest path from center to a point on the segment + double distanceSquared = GetNearest(segBegin, segEnd).LengthSquared; + + // The segment must cross the circle, hit + if (radiusSquared > distanceSquared) + { + return HitResult.Hit; + } + + Vector segVector = segEnd - segBegin; + HitResult result = HitResult.Right; + + // resolved two issues with the original code: + // 1. The local varial "normal" is assigned a value but it is never used afterwards. \ + // 2. the code indicates that that only case result is HitResult.InFront or HitResult.Behind is + // when WhereIsVectorAboutVector(-segBegin, segVector) == HitResult.Left. + + HitResult vResult = WhereIsVectorAboutVector(-segBegin, segVector); + + //either front or behind + if (vResult == HitResult.Hit) + { + result = DoubleUtil.LessThan(segBegin.LengthSquared, segEnd.LengthSquared) ? HitResult.InFront : + HitResult.Behind; + } + else + { + // Find the projection of center on the segment. + double findex = GetProjectionFIndex(segBegin, segEnd); + + // Get the normal vector, pointing from center to the projection point + Vector normal = segBegin + (segVector * findex); + + // recalculate distanceSquared using normal + distanceSquared = normal.LengthSquared; + + // The extension of the segment won't hit the circle + if (radiusSquared <= distanceSquared) + { + // either left or right + result = vResult; + } + else + { + result = (findex > 0) ? HitResult.InFront : HitResult.Behind; + } + } + + return result; + } + + /// + /// Finds out where the vector1 is about the vector2. + /// + internal static HitResult WhereIsVectorAboutVector(Vector vector1, Vector vector2) + { + double determinant = Vector.Determinant(vector1, vector2); + if (DoubleUtil.IsZero(determinant)) + { + return HitResult.Hit; // collinear + } + return (0 < determinant) ? HitResult.Left : HitResult.Right; + } + + /// + /// Tells whether the hitVector intersects the arc defined by two vectors. + /// + internal static HitResult WhereIsVectorAboutArc(Vector hitVector, Vector arcBegin, Vector arcEnd) + { + //HitResult result = HitResult.Right; + if (arcBegin == arcEnd) + { + // full circle + return HitResult.Hit; + } + + if (HitResult.Right == WhereIsVectorAboutVector(arcEnd, arcBegin)) + { + // small arc + if ((HitResult.Left != WhereIsVectorAboutVector(hitVector, arcBegin)) && + (HitResult.Right != WhereIsVectorAboutVector(hitVector, arcEnd))) + { + return HitResult.Hit; + } + } + else if ((HitResult.Left != WhereIsVectorAboutVector(hitVector, arcBegin)) || + (HitResult.Right != WhereIsVectorAboutVector(hitVector, arcEnd))) + { + return HitResult.Hit; + } + + if ((WhereIsVectorAboutVector(hitVector - arcBegin, TurnLeft(arcBegin)) != HitResult.Left) || + (WhereIsVectorAboutVector(hitVector - arcEnd, TurnRight(arcEnd)) != HitResult.Right)) + { + return HitResult.Left; + } + + return HitResult.Right; + } + + #endregion + + #region Misc. helpers + + /// + /// + /// + /// + /// + internal static Vector TurnLeft(Vector vector) + { + // this code is not called, but will be in VNext + throw new NotImplementedException(); + //return new Vector(-vector.Y, vector.X); + } + + /// + /// + /// + /// + /// + internal static Vector TurnRight(Vector vector) + { + // this code is not called, but will be in VNext + throw new NotImplementedException(); + //return new Vector(vector.Y, -vector.X); + } + + /// + /// + /// + /// + /// + /// + internal static bool IsOutside(HitResult hitResult, HitResult prevHitResult) + { + // ISSUE-2004/10/08-XiaoTu For Polygon and Circle, ((HitResult.Behind == hitResult) && (HitResult.InFront == prevHitResult)) + // cannot be true. + return ((HitResult.Left == hitResult) + || ((HitResult.Behind == hitResult) && (HitResult.InFront == prevHitResult))); + } + + /// + /// Internal helper function to find out the ratio of the distance from hitpoint to lineVector + /// and the distance from lineVector to (lineVector+nextLine) + /// + /// This is one edge of a polygonal node + /// The connection vector between the same edge on biginNode and ednNode + /// a point + /// the relative position of hitPoint + internal static double GetPositionBetweenLines(Vector linesVector, Vector nextLine, Vector hitPoint) + { + Vector nearestOnFirst = GetProjection(-hitPoint, linesVector - hitPoint); + + hitPoint = nextLine - hitPoint; + Vector nearestOnSecond = GetProjection(hitPoint, hitPoint + linesVector); + + Vector shortest = nearestOnFirst - nearestOnSecond; + System.Diagnostics.Debug.Assert((false == DoubleUtil.IsZero(shortest.X)) || (false == DoubleUtil.IsZero(shortest.Y))); + + //return DoubleUtil.IsZero(shortest.X) ? (nearestOnFirst.Y / shortest.Y) : (nearestOnFirst.X / shortest.X); + return Math.Sqrt(nearestOnFirst.LengthSquared / shortest.LengthSquared); + } + + /// + /// On a line defined buy two points finds the findex of the point + /// nearest to the origin (0,0). Same as FindNearestOnLine just + /// different output. + /// + /// A point on the line. + /// Another point on the line. + /// + internal static double GetProjectionFIndex(Vector begin, Vector end) + { + Vector segment = end - begin; + double lengthSquared = segment.LengthSquared; + + if (DoubleUtil.IsZero(lengthSquared)) + { + return 0; + } + + double dotProduct = -(begin * segment); + return AdjustFIndex(dotProduct / lengthSquared); + } + + /// + /// On a line defined buy two points finds the point nearest to the origin (0,0). + /// + /// A point on the line. + /// Another point on the line. + /// + internal static Vector GetProjection(Vector begin, Vector end) + { + double findex = GetProjectionFIndex(begin, end); + return (begin + (end - begin) * findex); + } + + /// + /// On a given segment finds the point nearest to the origin (0,0). + /// + /// The segment's begin point. + /// The segment's end point. + /// + internal static Vector GetNearest(Vector begin, Vector end) + { + double findex = GetProjectionFIndex(begin, end); + if (findex <= 0) + { + return begin; + } + if (findex >= 1) + { + return end; + } + return (begin + ((end - begin) * findex)); + } + + /// + /// Clears double's computation fuzz around 0 and 1 + /// + internal static double AdjustFIndex(double findex) + { + return DoubleUtil.IsZero(findex) ? 0 : (DoubleUtil.IsOne(findex) ? 1 : findex); + } + + #endregion + } +} + diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeRenderer.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeRenderer.cs new file mode 100644 index 0000000..a8c2128 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StrokeRenderer.cs @@ -0,0 +1,1112 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +//#define DEBUG_RENDERING_FEEDBACK + +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Input; +using System.Diagnostics; + +using SRID = MS.Internal.PresentationCore.SRID; +using MS.Internal; +using MS.Internal.PresentationCore; +using WpfInk; +using WpfInk.PresentationCore.System.Windows; +using WpfInk.PresentationCore.System.Windows.Ink; +using WpfInk.WindowsBase.System.Windows.Media; + +namespace MS.Internal.Ink +{ + /// + /// An internal utility class that knows how to render a stroke + /// into an Avalon's DrawingContext. + /// + internal static class StrokeRenderer + { + #region Static API + + + /// + /// Calculate the StreamGeometry for the StrokeNodes. + /// This method is one of our most sensitive perf paths. It has been optimized to + /// create the minimum path figures in the StreamGeometry. There are two structures + /// we create for each point in a stroke, the strokenode and the connecting quad. Adding + /// strokenodes is very expensive later when MIL renders it, so this method has been optimized + /// to only add strokenodes when either pressure changes, or the angle of the stroke changes. + /// + public static void CalcGeometryAndBoundsWithTransform(StrokeNodeIterator iterator, + DrawingAttributes drawingAttributes, + MatrixTypes stylusTipMatrixType, + bool calculateBounds, + IInternalStreamGeometryContext context, + out Rect bounds) + { + Debug.Assert(iterator != null); + Debug.Assert(drawingAttributes != null); + + //StreamGeometry streamGeometry = new StreamGeometry(); + //streamGeometry.FillRule = FillRule.Nonzero; + + //StreamGeometryContext context = streamGeometry.Open(); + //geometry = streamGeometry; + bounds = Rect.Empty; + try + { + List connectingQuadPoints = new List(iterator.Count * 4); + + //the index that the cb quad points are copied to + int cdIndex = iterator.Count * 2; + //the index that the ab quad points are copied to + int abIndex = 0; + for (int x = 0; x < cdIndex; x++) + { + //initialize so we can start copying to cdIndex later + connectingQuadPoints.Add(new Point(0d, 0d)); + } + + List strokeNodePoints = new List(); + double lastAngle = 0.0d; + bool previousPreviousNodeRendered = false; + + Rect lastRect = new Rect(0, 0, 0, 0); + + for (int index = 0; index < iterator.Count; index++) + { + StrokeNode strokeNode = iterator[index]; + System.Diagnostics.Debug.Assert(true == strokeNode.IsValid); + + //the only code that calls this with !calculateBounds + //is dynamic rendering, which already draws enough strokeNodes + //to hide any visual artifacts. + //static rendering calculatesBounds, and we use those + //bounds below to figure out what angle to lay strokeNodes down for. + Rect strokeNodeBounds = strokeNode.GetBounds(); + if (calculateBounds) + { + bounds.Union(strokeNodeBounds); + } + + //if the angle between this and the last position has changed + //too much relative to the angle between the last+1 position and the last position + //we need to lay down stroke node + double delta = Math.Abs(GetAngleDeltaFromLast(strokeNode.PreviousPosition, strokeNode.Position, ref lastAngle)); + + double angleTolerance = 45d; + if (stylusTipMatrixType == MatrixTypes.TRANSFORM_IS_UNKNOWN) + { + //probably a skew is thrown in, we need to fall back to being very conservative + //about how many strokeNodes we prune + angleTolerance = 10d; + } + else if (strokeNodeBounds.Height > 40d || strokeNodeBounds.Width > 40d) + { + //if the strokeNode gets above a certain size, we need to lay down more strokeNodes + //to prevent visual artifacts + angleTolerance = 20d; + } + bool directionChanged = delta > angleTolerance && delta < (360d - angleTolerance); + + double prevArea = lastRect.Height * lastRect.Width; + double currArea = strokeNodeBounds.Height * strokeNodeBounds.Width; + bool areaChangedOverThreshold = false; + if ((Math.Min(prevArea, currArea) / Math.Max(prevArea, currArea)) <= 0.70d) + { + //the min area is < 70% of the max area + areaChangedOverThreshold = true; + } + + lastRect = strokeNodeBounds; + + //render the stroke node for the first two nodes and last two nodes always + if (index <= 1 || index >= iterator.Count - 2 || directionChanged || areaChangedOverThreshold) + { + //special case... the direction has changed and we need to + //insert a stroke node in the StreamGeometry before we render the current one + if (directionChanged && !previousPreviousNodeRendered && index > 1 && index < iterator.Count - 1) + { + //insert a stroke node for the previous node + strokeNodePoints.Clear(); + strokeNode.GetPreviousContourPoints(strokeNodePoints); + AddFigureToStreamGeometryContext(context, strokeNodePoints, strokeNode.IsEllipse/*isBezierFigure*/); + + previousPreviousNodeRendered = true; + } + + //render the stroke node + strokeNodePoints.Clear(); + strokeNode.GetContourPoints(strokeNodePoints); + AddFigureToStreamGeometryContext(context, strokeNodePoints, strokeNode.IsEllipse/*isBezierFigure*/); + } + + if (!directionChanged) + { + previousPreviousNodeRendered = false; + } + + //add the end points of the connecting quad + Quad quad = strokeNode.GetConnectingQuad(); + if (!quad.IsEmpty) + { + connectingQuadPoints[abIndex++] = quad.A; + connectingQuadPoints[abIndex++] = quad.B; + connectingQuadPoints.Add(quad.D); + connectingQuadPoints.Add(quad.C); + } + + if (strokeNode.IsLastNode) + { + Debug.Assert(index == iterator.Count - 1); + if (abIndex > 0) + { + //we added something to the connecting quad points. + //now we need to do three things + //1) Shift the dc points down to the ab points + int cbStartIndex = iterator.Count * 2; + int cbEndIndex = connectingQuadPoints.Count - 1; + for (int i = abIndex, j = cbStartIndex; j <= cbEndIndex; i++, j++) + { + connectingQuadPoints[i] = connectingQuadPoints[j]; + } + + //2) trim the exess off the end of the array + int countToRemove = cbStartIndex - abIndex; + connectingQuadPoints.RemoveRange((cbEndIndex - countToRemove) + 1, countToRemove); + + //3) reverse the dc points to make them cd points + for (int i = abIndex, j = connectingQuadPoints.Count - 1; i < j; i++, j--) + { + Point temp = connectingQuadPoints[i]; + connectingQuadPoints[i] = connectingQuadPoints[j]; + connectingQuadPoints[j] = temp; + } + + //now render away! + AddFigureToStreamGeometryContext(context, connectingQuadPoints, false/*isBezierFigure*/); + } + } + } + } + finally + { + + } + } + + + /// + /// Calculate the StreamGeometry for the StrokeNodes. + /// This method is one of our most sensitive perf paths. It has been optimized to + /// create the minimum path figures in the StreamGeometry. There are two structures + /// we create for each point in a stroke, the strokenode and the connecting quad. Adding + /// strokenodes is very expensive later when MIL renders it, so this method has been optimized + /// to only add strokenodes when either pressure changes, or the angle of the stroke changes. + /// + public static void CalcGeometryAndBounds(StrokeNodeIterator iterator, + DrawingAttributes drawingAttributes, +#if DEBUG_RENDERING_FEEDBACK + DrawingContext debugDC, + double feedbackSize, + bool showFeedback, +#endif + bool calculateBounds, + IInternalStreamGeometryContext context, + out Rect bounds) + { + Debug.Assert(iterator != null && drawingAttributes != null); + + //we can use our new algorithm for identity only. + Matrix stylusTipTransform = drawingAttributes.StylusTipTransform; + if (stylusTipTransform != Matrix.Identity && stylusTipTransform._type != MatrixTypes.TRANSFORM_IS_SCALING) + { + //second best optimization + CalcGeometryAndBoundsWithTransform(iterator, drawingAttributes, stylusTipTransform._type, calculateBounds, context, out bounds); + } + else + { + //StreamGeometry streamGeometry = new StreamGeometry(); + //streamGeometry.FillRule = FillRule.Nonzero; + + //IStreamGeometryContext context = streamGeometry.Open(); + //geometry = streamGeometry; + Rect empty = Rect.Empty; + bounds = empty; + try + { + // + // We keep track of three StrokeNodes as we iterate across + // the Stroke. Since these are structs, the default ctor will + // be called and .IsValid will be false until we initialize them + // + StrokeNode emptyStrokeNode = new StrokeNode(); + StrokeNode prevPrevStrokeNode = new StrokeNode(); + StrokeNode prevStrokeNode = new StrokeNode(); + StrokeNode strokeNode = new StrokeNode(); + + Rect prevPrevStrokeNodeBounds = empty; + Rect prevStrokeNodeBounds = empty; + Rect strokeNodeBounds = empty; + + //percentIntersect is a function of drawingAttributes height / width + double percentIntersect = 95d; + double maxExtent = Math.Max(drawingAttributes.Height, drawingAttributes.Width); + percentIntersect += Math.Min(4.99999d, ((maxExtent / 20d) * 5d)); + + double prevAngle = double.MinValue; + bool isStartOfSegment = true; + bool isEllipse = drawingAttributes.StylusTip == StylusTip.Ellipse; + bool ignorePressure = drawingAttributes.IgnorePressure; + // + // Two List's that get reused for adding figures + // to the streamgeometry. + // + List pathFigureABSide = new List();//don't prealloc. It causes Gen2 collections to rise and doesn't help execution time + List pathFigureDCSide = new List(); + List polyLinePoints = new List(4); + + int iteratorCount = iterator.Count; + for (int index = 0, previousIndex = -1; index < iteratorCount;) + { + if (!prevPrevStrokeNode.IsValid) + { + if (prevStrokeNode.IsValid) + { + //we're sliding our pointers forward + prevPrevStrokeNode = prevStrokeNode; + prevPrevStrokeNodeBounds = prevStrokeNodeBounds; + prevStrokeNode = emptyStrokeNode; + } + else + { + prevPrevStrokeNode = iterator[index++, previousIndex++]; + prevPrevStrokeNodeBounds = prevPrevStrokeNode.GetBounds(); + continue; //so we always check if index < iterator.Count + } + } + + //we know prevPrevStrokeNode is valid + if (!prevStrokeNode.IsValid) + { + if (strokeNode.IsValid) + { + //we're sliding our pointers forward + prevStrokeNode = strokeNode; + prevStrokeNodeBounds = strokeNodeBounds; + strokeNode = emptyStrokeNode; + } + else + { + //get the next strokeNode, but don't automatically update previousIndex + prevStrokeNode = iterator[index++, previousIndex]; + prevStrokeNodeBounds = prevStrokeNode.GetBounds(); + + RectCompareResult result = + FuzzyContains(prevStrokeNodeBounds, + prevPrevStrokeNodeBounds, + isStartOfSegment ? 99.99999d : percentIntersect); + + if (result == RectCompareResult.Rect1ContainsRect2) + { + // this node already contains the prevPrevStrokeNodeBounds (PP): + // + // |------------| + // | |----| | + // | | PP | P | + // | |----| | + // |------------| + // + prevPrevStrokeNode = iterator[index - 1, prevPrevStrokeNode.Index - 1]; ; + prevPrevStrokeNodeBounds = Rect.Union(prevStrokeNodeBounds, prevPrevStrokeNodeBounds); + + // at this point prevPrevStrokeNodeBounds already contains this node + // we can just ignore this node + prevStrokeNode = emptyStrokeNode; + + // update previousIndex to point to this node + previousIndex = index - 1; + + // go back to our main loop + continue; + } + else if (result == RectCompareResult.Rect2ContainsRect1) + { + // this prevPrevStrokeNodeBounds (PP) already contains this node: + // + // |------------| + // | |----|| + // | PP | P || + // | |----|| + // |------------| + // + + //prevPrevStrokeNodeBounds already contains this node + //we can just ignore this node + prevStrokeNode = emptyStrokeNode; + + // go back to our main loop, but do not update previousIndex + // because it should continue to point to previousPrevious + continue; + } + + Debug.Assert(!prevStrokeNode.GetConnectingQuad().IsEmpty, "prevStrokeNode.GetConnectingQuad() is Empty!"); + + // if neither was true, we now have two of our three nodes required to + // start our computation, we need to update previousIndex to point + // to our current, valid prevStrokeNode + previousIndex = index - 1; + continue; //so we always check if index < iterator.Count + } + } + + //we know prevPrevStrokeNode and prevStrokeNode are both valid + if (!strokeNode.IsValid) + { + strokeNode = iterator[index++, previousIndex]; + strokeNodeBounds = strokeNode.GetBounds(); + + RectCompareResult result = + FuzzyContains(strokeNodeBounds, + prevStrokeNodeBounds, + isStartOfSegment ? 99.99999 : percentIntersect); + + RectCompareResult result2 = + FuzzyContains(strokeNodeBounds, + prevPrevStrokeNodeBounds, + isStartOfSegment ? 99.99999 : percentIntersect); + + if (isStartOfSegment && + result == RectCompareResult.Rect1ContainsRect2 && + result2 == RectCompareResult.Rect1ContainsRect2) + { + if (pathFigureABSide.Count > 0) + { + //we've started a stroke, we need to end it before resetting + //prevPrev +#if DEBUG_RENDERING_FEEDBACK + prevStrokeNode.GetPointsAtEndOfSegment(pathFigureABSide, pathFigureDCSide, debugDC, feedbackSize, showFeedback); +#else + prevStrokeNode.GetPointsAtEndOfSegment(pathFigureABSide, pathFigureDCSide); +#endif + //render + ReverseDCPointsRenderAndClear(context, pathFigureABSide, pathFigureDCSide, polyLinePoints, isEllipse, true/*clear the point collections*/); + } + //we're resetting + //prevPrevStrokeNode. We need to gen one + //without a connecting quad + prevPrevStrokeNode = iterator[index - 1, prevPrevStrokeNode.Index - 1]; + prevPrevStrokeNodeBounds = prevPrevStrokeNode.GetBounds(); + prevStrokeNode = emptyStrokeNode; + strokeNode = emptyStrokeNode; + + // increment previousIndex to to point to this node + previousIndex = index - 1; + continue; + } + else if (result == RectCompareResult.Rect1ContainsRect2) + { + // this node (C) already contains the prevStrokeNodeBounds (P): + // + // |------------| + // |----| | |----| | + // | PP | | | P | C | + // |----| | |----| | + // |------------| + // + //we have to generate a new stroke node that points + //to pp since the connecting quad from C to P could be empty + //if they have the same point + strokeNode = iterator[index - 1, prevStrokeNode.Index - 1]; + if (!strokeNode.GetConnectingQuad().IsEmpty) + { + //only update prevStrokeNode if we have a valid connecting quad + prevStrokeNode = strokeNode; + prevStrokeNodeBounds = Rect.Union(strokeNodeBounds, prevStrokeNodeBounds); + + // update previousIndex, since it should point to this node now + previousIndex = index - 1; + } + + // at this point we can just ignore this node + strokeNode = emptyStrokeNode; + //strokeNodeBounds = empty; + + prevAngle = double.MinValue; //invalidate + + // go back to our main loop + continue; + } + else if (result == RectCompareResult.Rect2ContainsRect1) + { + // this prevStrokeNodeBounds (P) already contains this node (C): + // + // |------------| + // |----| | |----|| + // | PP | | P | C || + // |----| | |----|| + // |------------| + // + //prevStrokeNodeBounds already contains this node + //we can just ignore this node + strokeNode = emptyStrokeNode; + + // go back to our main loop, but do not update previousIndex + // because it should continue to point to previous + continue; + } + + Debug.Assert(!strokeNode.GetConnectingQuad().IsEmpty, "strokeNode.GetConnectingQuad was empty, this is unexpected"); + + // + // NOTE: we do not check if C contains PP, or PP contains C because + // that indicates a change in direction, which we handle below + // + // if neither was true P and C are separate, + // we now have all three nodes required to + // start our computation, we need to update previousIndex to point + // to our current, valid prevStrokeNode + previousIndex = index - 1; + } + + + // see if we have an overlap between the first and third node + bool overlap = prevPrevStrokeNodeBounds.IntersectsWith(strokeNodeBounds); + + // prevPrevStrokeNode, prevStrokeNode and strokeNode are all + // valid nodes now. Now we need to figure out what do add to our + // PathFigure. First calc bounds on the strokeNode we know we need to render + if (calculateBounds) + { + bounds.Union(prevStrokeNodeBounds); + } + + // determine what points to add to pathFigureABSide and pathFigureDCSide + // from prevPrevStrokeNode + if (pathFigureABSide.Count == 0) + { + Debug.Assert(pathFigureDCSide.Count == 0); + if (calculateBounds) + { + bounds.Union(prevPrevStrokeNodeBounds); + } + + if (isStartOfSegment && overlap) + { + //render a complete first stroke node or we can get artifacts + prevPrevStrokeNode.GetContourPoints(polyLinePoints); + AddFigureToStreamGeometryContext(context, polyLinePoints, prevPrevStrokeNode.IsEllipse/*isBezierFigure*/); + polyLinePoints.Clear(); + } + + // we're starting a new pathfigure + // we need to add parts of the prevPrevStrokeNode contour + // to pathFigureABSide and pathFigureDCSide +#if DEBUG_RENDERING_FEEDBACK + prevStrokeNode.GetPointsAtStartOfSegment(pathFigureABSide, pathFigureDCSide, debugDC, feedbackSize, showFeedback); +#else + prevStrokeNode.GetPointsAtStartOfSegment(pathFigureABSide, pathFigureDCSide); +#endif + + //set our marker, we're no longer at the start of the stroke + isStartOfSegment = false; + } + + + + if (prevAngle == double.MinValue) + { + //prevAngle is no longer valid + prevAngle = GetAngleBetween(prevPrevStrokeNode.Position, prevStrokeNode.Position); + } + double delta = GetAngleDeltaFromLast(prevStrokeNode.Position, strokeNode.Position, ref prevAngle); + bool directionChangedOverAbsoluteThreshold = Math.Abs(delta) > 90d && Math.Abs(delta) < (360d - 90d); + bool directionChangedOverOverlapThreshold = overlap && !(ignorePressure || strokeNode.PressureFactor == 1f) && Math.Abs(delta) > 30d && Math.Abs(delta) < (360d - 30d); + + double prevArea = prevStrokeNodeBounds.Height * prevStrokeNodeBounds.Width; + double currArea = strokeNodeBounds.Height * strokeNodeBounds.Width; + + bool areaChanged = !(prevArea == currArea && prevArea == (prevPrevStrokeNodeBounds.Height * prevPrevStrokeNodeBounds.Width)); + bool areaChangeOverThreshold = false; + if (overlap && areaChanged) + { + if ((Math.Min(prevArea, currArea) / Math.Max(prevArea, currArea)) <= 0.90d) + { + //the min area is < 70% of the max area + areaChangeOverThreshold = true; + } + } + + if (areaChanged || delta != 0.0d || index >= iteratorCount) + { + //the area changed between the three nodes OR there was an angle delta OR we're at the end + //of the stroke... either way, this is a significant node. If not, we're going to drop it. + if ((overlap && (directionChangedOverOverlapThreshold || areaChangeOverThreshold)) || + directionChangedOverAbsoluteThreshold) + { + // + // we need to stop the pathfigure at P + // and render the pathfigure + // + // |--| |--| |--||--| |------| + // |PP|------|P | |PP||P | |PP P C| + // |--| |--| |--||--| |------| + // / |C | + // |--| |--| + // |C | + // |--| + + +#if DEBUG_RENDERING_FEEDBACK + prevStrokeNode.GetPointsAtEndOfSegment(pathFigureABSide, pathFigureDCSide, debugDC, feedbackSize, showFeedback); +#else + //end the figure + prevStrokeNode.GetPointsAtEndOfSegment(pathFigureABSide, pathFigureDCSide); +#endif + //render + ReverseDCPointsRenderAndClear(context, pathFigureABSide, pathFigureDCSide, polyLinePoints, isEllipse, true/*clear the point collections*/); + + if (areaChangeOverThreshold) + { + //render a complete stroke node or we can get artifacts + prevStrokeNode.GetContourPoints(polyLinePoints); + AddFigureToStreamGeometryContext(context, polyLinePoints, prevStrokeNode.IsEllipse/*isBezierFigure*/); + polyLinePoints.Clear(); + } + } + else + { + // + // direction didn't change over the threshold, add the midpoint data + // |--| |--| + // |PP|------|P | + // |--| |--| + // \ + // |--| + // |C | + // |--| + bool endSegment; //flag that tell us if we missed an intersection +#if DEBUG_RENDERING_FEEDBACK + strokeNode.GetPointsAtMiddleSegment(prevStrokeNode, delta, pathFigureABSide, pathFigureDCSide, out endSegment, debugDC, feedbackSize, showFeedback); +#else + strokeNode.GetPointsAtMiddleSegment(prevStrokeNode, delta, pathFigureABSide, pathFigureDCSide, out endSegment); +#endif + if (endSegment) + { + //we have a missing intersection, we need to end the + //segment at P +#if DEBUG_RENDERING_FEEDBACK + prevStrokeNode.GetPointsAtEndOfSegment(pathFigureABSide, pathFigureDCSide, debugDC, feedbackSize, showFeedback); +#else + //end the figure + prevStrokeNode.GetPointsAtEndOfSegment(pathFigureABSide, pathFigureDCSide); +#endif + //render + ReverseDCPointsRenderAndClear(context, pathFigureABSide, pathFigureDCSide, polyLinePoints, isEllipse, true/*clear the point collections*/); + } + } + } + + // + // either way... slide our pointers forward, to do this, we simply mark + // our first pointer as 'empty' + // + prevPrevStrokeNode = emptyStrokeNode; + prevPrevStrokeNodeBounds = empty; + } + + // + // anything left to render? + // + if (prevPrevStrokeNode.IsValid) + { + if (prevStrokeNode.IsValid) + { + if (calculateBounds) + { + bounds.Union(prevPrevStrokeNodeBounds); + bounds.Union(prevStrokeNodeBounds); + } + Debug.Assert(!strokeNode.IsValid); + // + // we never made it to strokeNode, render two points, OR + // strokeNode was a dupe + // + if (pathFigureABSide.Count > 0) + { +#if DEBUG_RENDERING_FEEDBACK + prevStrokeNode.GetPointsAtEndOfSegment(pathFigureABSide, pathFigureDCSide, debugDC, feedbackSize, showFeedback); +#else + // + // strokeNode was a dupe, we just need to render the end of the stroke + // which is at prevStrokeNode + // + prevStrokeNode.GetPointsAtEndOfSegment(pathFigureABSide, pathFigureDCSide); +#endif + //render + ReverseDCPointsRenderAndClear(context, pathFigureABSide, pathFigureDCSide, polyLinePoints, isEllipse, false/*clear the point collections*/); + } + else + { + // we've only seen two points to render + Debug.Assert(pathFigureDCSide.Count == 0); + //contains all the logic to render two stroke nodes + RenderTwoStrokeNodes(context, + prevPrevStrokeNode, + prevPrevStrokeNodeBounds, + prevStrokeNode, + prevStrokeNodeBounds, + pathFigureABSide, + pathFigureDCSide, + polyLinePoints +#if DEBUG_RENDERING_FEEDBACK + ,debugDC, + feedbackSize, + showFeedback +#endif + ); + } + } + else + { + if (calculateBounds) + { + bounds.Union(prevPrevStrokeNodeBounds); + } + + // we only have a single point to render + Debug.Assert(pathFigureABSide.Count == 0); + prevPrevStrokeNode.GetContourPoints(pathFigureABSide); + AddFigureToStreamGeometryContext(context, pathFigureABSide, prevPrevStrokeNode.IsEllipse/*isBezierFigure*/); + } + } + else if (prevStrokeNode.IsValid && strokeNode.IsValid) + { + if (calculateBounds) + { + bounds.Union(prevStrokeNodeBounds); + bounds.Union(strokeNodeBounds); + } + + // typical case, we hit the end of the stroke + // see if we need to start a stroke, or just end one + if (pathFigureABSide.Count > 0) + { +#if DEBUG_RENDERING_FEEDBACK + strokeNode.GetPointsAtEndOfSegment(pathFigureABSide, pathFigureDCSide, debugDC, feedbackSize, showFeedback); +#else + strokeNode.GetPointsAtEndOfSegment(pathFigureABSide, pathFigureDCSide); +#endif + + //render + ReverseDCPointsRenderAndClear(context, pathFigureABSide, pathFigureDCSide, polyLinePoints, isEllipse, false/*clear the point collections*/); + + if (FuzzyContains(strokeNodeBounds, prevStrokeNodeBounds, 70d) != RectCompareResult.NoItersection) + { + //render a complete stroke node or we can get artifacts + strokeNode.GetContourPoints(polyLinePoints); + AddFigureToStreamGeometryContext(context, polyLinePoints, strokeNode.IsEllipse/*isBezierFigure*/); + } + } + else + { + Debug.Assert(pathFigureDCSide.Count == 0); + //contains all the logic to render two stroke nodes + RenderTwoStrokeNodes(context, + prevStrokeNode, + prevStrokeNodeBounds, + strokeNode, + strokeNodeBounds, + pathFigureABSide, + pathFigureDCSide, + polyLinePoints +#if DEBUG_RENDERING_FEEDBACK + ,debugDC, + feedbackSize, + showFeedback +#endif + ); + } + } + } + finally + { + //context.Close(); + //geometry.Freeze(); + } + } + } + + + /// + /// Helper routine to render two distinct stroke nodes + /// + private static void RenderTwoStrokeNodes(IInternalStreamGeometryContext context, + StrokeNode strokeNodePrevious, + Rect strokeNodePreviousBounds, + StrokeNode strokeNodeCurrent, + Rect strokeNodeCurrentBounds, + List pointBuffer1, + List pointBuffer2, + List pointBuffer3 +#if DEBUG_RENDERING_FEEDBACK + ,DrawingContext debugDC, + double feedbackSize, + bool showFeedback +#endif + ) + { + Debug.Assert(pointBuffer1 != null); + Debug.Assert(pointBuffer2 != null); + Debug.Assert(pointBuffer3 != null); + Debug.Assert(context != null); + + + //see if we need to render a quad - if there is not at least a 70% overlap + if (FuzzyContains(strokeNodePreviousBounds, strokeNodeCurrentBounds, 70d) != RectCompareResult.NoItersection) + { + //we're between 100% and 70% overlapped + //just render two distinct figures with a connecting quad (if needed) + strokeNodePrevious.GetContourPoints(pointBuffer1); + AddFigureToStreamGeometryContext(context, pointBuffer1, strokeNodePrevious.IsEllipse/*isBezierFigure*/); + + Quad quad = strokeNodeCurrent.GetConnectingQuad(); + if (!quad.IsEmpty) + { + pointBuffer3.Add(quad.A); + pointBuffer3.Add(quad.B); + pointBuffer3.Add(quad.C); + pointBuffer3.Add(quad.D); + AddFigureToStreamGeometryContext(context, pointBuffer3, false/*isBezierFigure*/); + } + + strokeNodeCurrent.GetContourPoints(pointBuffer2); + AddFigureToStreamGeometryContext(context, pointBuffer2, strokeNodeCurrent.IsEllipse/*isBezierFigure*/); + } + else + { + //we're less than 70% overlapped, it's safe to run our optimization +#if DEBUG_RENDERING_FEEDBACK + strokeNodeCurrent.GetPointsAtStartOfSegment(pointBuffer1, pointBuffer2, debugDC, feedbackSize, showFeedback); + strokeNodeCurrent.GetPointsAtEndOfSegment(pointBuffer1, pointBuffer2, debugDC, feedbackSize, showFeedback); +#else + strokeNodeCurrent.GetPointsAtStartOfSegment(pointBuffer1, pointBuffer2); + strokeNodeCurrent.GetPointsAtEndOfSegment(pointBuffer1, pointBuffer2); +#endif + //render + ReverseDCPointsRenderAndClear(context, pointBuffer1, pointBuffer2, pointBuffer3, strokeNodeCurrent.IsEllipse, false/*clear the point collections*/); + } + } + + /// + /// ReverseDCPointsRenderAndClear + /// + private static void ReverseDCPointsRenderAndClear(IInternalStreamGeometryContext context, List abPoints, List dcPoints, List polyLinePoints, bool isEllipse, bool clear) + { + //we need to reverse the cd side points + Point temp; + for (int i = 0, j = dcPoints.Count - 1; i < j; i++, j--) + { + temp = dcPoints[i]; + dcPoints[i] = dcPoints[j]; + dcPoints[j] = temp; + } + if (isEllipse) + { + AddArcToFigureToStreamGeometryContext(context, abPoints, dcPoints, polyLinePoints); + } + else + { + //for rectangles, render a single path figure by combining both sides + AddPolylineFigureToStreamGeometryContext(context, abPoints, dcPoints); + } + + if (clear) + { + abPoints.Clear(); + dcPoints.Clear(); + } + } + /// + /// FuzzyContains for two rects + /// + private static RectCompareResult FuzzyContains(Rect rect1, Rect rect2, double percentIntersect) + { + Debug.Assert(percentIntersect >= 0.0 && percentIntersect <= 100.0d); + + + double intersectLeft = Math.Max(rect1.Left, rect2.Left); + double intersectTop = Math.Max(rect1.Top, rect2.Top); + double intersectWidth = Math.Max((double) (Math.Min(rect1.Right, rect2.Right) - intersectLeft), (double) 0); + double intersectHeight = Math.Max((double) (Math.Min(rect1.Bottom, rect2.Bottom) - intersectTop), (double) 0); + + if (intersectWidth == 0.0d || intersectHeight == 0.0d) + { + return RectCompareResult.NoItersection; + } + + //we have an intersection, see if it is enough + double rect1Area = rect1.Height * rect1.Width; + double rect2Area = rect2.Height * rect2.Width; + double minArea = Math.Min(rect1Area, rect2Area); + double intersectionArea = intersectWidth * intersectHeight; + double intersect = (intersectionArea / minArea) * 100d; + if (intersect >= percentIntersect) + { + if (rect1Area >= rect2Area) + { + return RectCompareResult.Rect1ContainsRect2; + } + return RectCompareResult.Rect2ContainsRect1; + } + + return RectCompareResult.NoItersection; + } + + /// + /// Private helper to render a path figure to the SGC + /// + private static void AddFigureToStreamGeometryContext(IInternalStreamGeometryContext context, List points, bool isBezierFigure) + { + Debug.Assert(context != null); + Debug.Assert(points != null); + Debug.Assert(points.Count > 0); + + context.BeginFigure(points[points.Count - 1], //start point + true, //isFilled + true); //IsClosed + + if (isBezierFigure) + { + context.PolyBezierTo(points, + true, //isStroked + true); //isSmoothJoin + } + else + { + context.PolyLineTo(points, + true, //isStroked + true); //isSmoothJoin + } + } + + + /// + /// Private helper to render a path figure to the SGC + /// + private static void AddPolylineFigureToStreamGeometryContext(IInternalStreamGeometryContext context, List abPoints, List dcPoints) + { + Debug.Assert(context != null); + Debug.Assert(abPoints != null && dcPoints != null); + Debug.Assert(abPoints.Count > 0 && dcPoints.Count > 0); + + context.BeginFigure(abPoints[0], //start point + true, //isFilled + true); //IsClosed + + context.PolyLineTo(abPoints, + true, //isStroked + true); //isSmoothJoin + + context.PolyLineTo(dcPoints, + true, //isStroked + true); //isSmoothJoin + } + + /// + /// Private helper to render a path figure to the SGC + /// + private static void AddArcToFigureToStreamGeometryContext(IInternalStreamGeometryContext context, List abPoints, List dcPoints, List polyLinePoints) + { + Debug.Assert(context != null); + Debug.Assert(abPoints != null && dcPoints != null); + Debug.Assert(polyLinePoints != null); + //Debug.Assert(abPoints.Count > 0 && dcPoints.Count > 0); + if (abPoints.Count == 0 || dcPoints.Count == 0) + { + return; + } + + context.BeginFigure(abPoints[0], //start point + true, //isFilled + true); //IsClosed + + for (int j = 0; j < 2; j++) + { + List points = j == 0 ? abPoints : dcPoints; + int startIndex = j == 0 ? 1 : 0; + for (int i = startIndex; i < points.Count;) + { + Point next = points[i]; + if (next == StrokeRenderer.ArcToMarker) + { + if (polyLinePoints.Count > 0) + { + //polyline first + context.PolyLineTo(polyLinePoints, + true, //isStroked + true); //isSmoothJoin + polyLinePoints.Clear(); + } + //we're arcing, pull out height, width and the arc to point + Debug.Assert(i + 2 < points.Count); + if (i + 2 < points.Count) + { + Point sizePoint = points[i + 1]; + Size ellipseSize = new Size(sizePoint.X / 2/*width*/, sizePoint.Y / 2/*height*/); + Point arcToPoint = points[i + 2]; + + bool isLargeArc = false; //>= 180 + + context.ArcTo(arcToPoint, + ellipseSize, + 0d, //rotation + isLargeArc, //isLargeArc + sweepDirection: true, // SweepDirection.Clockwise + true, //isStroked + true); //isSmoothJoin + } + i += 3; //advance past this arcTo block + } + else + { + //walk forward until we find an arc marker or the end + polyLinePoints.Add(next); + i++; + } + } + if (polyLinePoints.Count > 0) + { + //polyline + context.PolyLineTo(polyLinePoints, + true, //isStroked + true); //isSmoothJoin + polyLinePoints.Clear(); + } + } + } + + /// + /// calculates the angle between the previousPosition and the current one and then computes the delta between + /// the lastAngle. lastAngle is also updated + /// + private static double GetAngleDeltaFromLast(Point previousPosition, Point currentPosition, ref double lastAngle) + { + double delta = 0.0d; + + //input points typically come in very close to each other + double dx = (currentPosition.X * 1000) - (previousPosition.X * 1000); + double dy = (currentPosition.Y * 1000) - (previousPosition.Y * 1000); + if ((Int64) dx == 0 && (Int64) dy == 0) + { + //the points are close enough not to matter + //don't update lastAngle + return delta; + } + + double angle = GetAngleBetween(previousPosition, currentPosition); + + //special case when angle / lastAngle span 0 degrees + if (lastAngle >= 270 && angle <= 90) + { + delta = lastAngle - (360d + angle); + } + else if (lastAngle <= 90 && angle >= 270) + { + delta = (360d + lastAngle) - angle; + } + else + { + delta = (lastAngle - angle); + } + lastAngle = angle; + + // Return + return delta; + } + + /// + /// calculates the angle between the previousPosition and the current one and then computes the delta between + /// the lastAngle. lastAngle is also updated + /// + private static double GetAngleBetween(Point previousPosition, Point currentPosition) + { + double angle = 0.0d; + + //input points typically come in very close to each other + double dx = (currentPosition.X * 1000) - (previousPosition.X * 1000); + double dy = (currentPosition.Y * 1000) - (previousPosition.Y * 1000); + if ((Int64) dx == 0 && (Int64) dy == 0) + { + //the points are close enough not to matter + return angle; + } + + // Calculate angle + if (dx == 0.0) + { + if (dy == 0.0) + { + angle = 0.0; + } + else if (dy > 0.0) + { + angle = Math.PI / 2.0; + } + else + { + angle = Math.PI * 3.0 / 2.0; + } + } + else if (dy == 0.0) + { + if (dx > 0.0) + { + angle = 0.0; + } + else + { + angle = Math.PI; + } + } + else + { + if (dx < 0.0) + { + angle = Math.Atan(dy / dx) + Math.PI; + } + else if (dy < 0.0) + { + angle = Math.Atan(dy / dx) + (2 * Math.PI); + } + else + { + angle = Math.Atan(dy / dx); + } + } + + // Convert to degrees + angle = angle * 180 / Math.PI; + + // Return + return angle; + } + + // Opacity for highlighter container visuals + internal static readonly double HighlighterOpacity = 0.5; + internal static readonly byte SolidStrokeAlpha = 0xFF; + internal static readonly Point ArcToMarker = new Point(Double.MinValue, Double.MinValue); + + /// + /// Simple helper enum + /// + private enum RectCompareResult + { + Rect1ContainsRect2, + Rect2ContainsRect1, + NoItersection, + } + #endregion + } +} + diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StylusShape.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StylusShape.cs new file mode 100644 index 0000000..acc1c64 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/MS/Internal/Ink/StylusShape.cs @@ -0,0 +1,375 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +using System; +using MS.Internal; +using WpfInk.WindowsBase.System.Windows.Media; + +namespace WpfInk.PresentationCore.System.Windows.Ink +{ + /// + /// Defines the style of pen tip for rendering. + /// + /// + /// The Stylus size and coordinates are in units equal to 1/96th of an inch. + /// The default in V1 the default width is 1 pixel. This is 53 himetric units. + /// There are 2540 himetric units per inch. + /// This means that 53 high metric units is equivalent to 53/2540*96 in avalon. + /// + internal abstract class StylusShape + { + #region Fields + + private double m_width; + private double m_height; + private double m_rotation; + private Point[] m_vertices; + private StylusTip m_tip; + private Matrix _transform = Matrix.Identity; + + #endregion + + #region Constructors + + internal StylusShape() { } + + /// + /// constructor for a StylusShape. + /// + internal StylusShape(StylusTip tip, double width, double height, double rotation) + { + if (Double.IsNaN(width) || Double.IsInfinity(width) || width < DrawingAttributes.MinWidth || width > DrawingAttributes.MaxWidth) + { + throw new ArgumentOutOfRangeException("width"); + } + + if (Double.IsNaN(height) || Double.IsInfinity(height) || height < DrawingAttributes.MinHeight || height > DrawingAttributes.MaxHeight) + { + throw new ArgumentOutOfRangeException("height"); + } + + if (Double.IsNaN(rotation) || Double.IsInfinity(rotation)) + { + throw new ArgumentOutOfRangeException("rotation"); + } + + if (!StylusTipHelper.IsDefined(tip)) + { + throw new ArgumentOutOfRangeException("tip"); + } + + + // + // mod rotation to 360 (720 to 0, 361 to 1, -270 to 90) + // + m_width = width; + m_height = height; + m_rotation = rotation == 0 ? 0 : rotation % 360; + m_tip = tip; + if (tip == StylusTip.Rectangle) + { + ComputeRectangleVertices(); + } + } + + #endregion + + #region Public properties + + /// + /// Width of the non-rotated shape. + /// + public double Width { get { return m_width; } } + + /// + /// Height of the non-rotated shape. + /// + public double Height { get { return m_height; } } + + /// + /// The shape's rotation angle. The rotation is done about the origin (0,0). + /// + public double Rotation { get { return m_rotation; } } + + /// + /// GetVerticesAsVectors + /// + /// + internal Vector[] GetVerticesAsVectors() + { + Vector[] vertices; + + if (null != m_vertices) + { + // For a Rectangle + vertices = new Vector[m_vertices.Length]; + + if (_transform.IsIdentity) + { + for (int i = 0; i < vertices.Length; i++) + { + vertices[i] = (Vector) m_vertices[i]; + } + } + else + { + for (int i = 0; i < vertices.Length; i++) + { + vertices[i] = _transform.Transform((Vector) m_vertices[i]); + } + + // A transform might make the vertices in counter-clockwise order + // Fix it if this is the case. + FixCounterClockwiseVertices(vertices); + } + } + else + { + // For ellipse + + // The transform is already applied on these points. + Point[] p = GetBezierControlPoints(); + vertices = new Vector[p.Length]; + for (int i = 0; i < vertices.Length; i++) + { + vertices[i] = (Vector) p[i]; + } + } + return vertices; + } + + #endregion + + #region Misc. internal API + + /// + /// This is the transform on the StylusShape + /// + internal Matrix Transform + { + get + { + return _transform; + } + set + { + global::System.Diagnostics.Debug.Assert(value.HasInverse); + _transform = value; + } + } + + /// + /// A helper property. + /// + internal bool IsEllipse { get { return (null == m_vertices); } } + + /// + /// A helper property. + /// + internal bool IsPolygon { get { return (null != m_vertices); } } + + /// + /// Generally, there's no need for the shape's bounding box. + /// We use it to approximate v2 shapes with a rectangle for v1. + /// + internal Rect BoundingBox + { + get + { + Rect bbox; + + if (this.IsPolygon) + { + bbox = Rect.Empty; + foreach (Point vertex in m_vertices) + { + bbox.Union(vertex); + } + } + // Future enhancement: Implement bbox for rotated ellipses. + else //if (DoubleUtil.IsZero(m_rotation) || DoubleUtil.AreClose(m_width, m_height)) + { + bbox = new Rect(-(m_width * 0.5), -(m_height * 0.5), m_width, m_height); + } + //else + //{ + // throw new NotImplementedException("Rotated ellipse"); + //} + + return bbox; + } + } + #endregion + + #region Implementation helpers + /// TBS + private void ComputeRectangleVertices() + { + Point topLeft = new Point(-(m_width * 0.5), -(m_height * 0.5)); + m_vertices = new Point[4] { topLeft, + topLeft + new Vector(m_width, 0), + topLeft + new Vector(m_width, m_height), + topLeft + new Vector(0, m_height)}; + if (false == DoubleUtil.IsZero(m_rotation)) + { + Matrix rotationTransform = Matrix.Identity; + rotationTransform.Rotate(m_rotation); + rotationTransform.Transform(m_vertices); + } + } + + + /// A transform might make the vertices in counter-clockwise order Fix it if this is the case. + private void FixCounterClockwiseVertices(Vector[] vertices) + { + // The private method should only called for Rectangle case. + global::System.Diagnostics.Debug.Assert(vertices.Length == 4); + + Point prevVertex = (Point) vertices[vertices.Length - 1]; + int counterClockIndex = 0, clockWiseIndex = 0; + + for (int i = 0; i < vertices.Length; i++) + { + Point vertex = (Point) vertices[i]; + Vector edge = vertex - prevVertex; + + // Verify that the next vertex is on the right side off the edge vector. + double det = Vector.Determinant(edge, (Point) vertices[(i + 1) % vertices.Length] - (Point) vertex); + if (0 > det) + { + counterClockIndex++; + } + else if (0 < det) + { + clockWiseIndex++; + } + + prevVertex = vertex; + } + + // Assert the transform will make it either clockwise or counter-clockwise. + global::System.Diagnostics.Debug.Assert(clockWiseIndex == vertices.Length || counterClockIndex == vertices.Length); + + if (counterClockIndex == vertices.Length) + { + // Make it Clockwise + int lastIndex = vertices.Length - 1; + for (int j = 0; j < vertices.Length / 2; j++) + { + Vector tmp = vertices[j]; + vertices[j] = vertices[lastIndex - j]; + vertices[lastIndex - j] = tmp; + } + } + } + + + private Point[] GetBezierControlPoints() + { + global::System.Diagnostics.Debug.Assert(m_tip == StylusTip.Ellipse); + + // Approximating a 1/4 circle with a Bezier curve (borrowed from Avalon's EllipseGeometry.cs) + const double ArcAsBezier = 0.5522847498307933984; // =(\/2 - 1)*4/3 + + double radiusX = m_width / 2; + double radiusY = m_height / 2; + double borderMagicX = radiusX * ArcAsBezier; + double borderMagicY = radiusY * ArcAsBezier; + + Point[] controlPoints = new Point[] { + new Point( -radiusX, -borderMagicY), + new Point(-borderMagicX, -radiusY), + new Point( 0, -radiusY), + new Point( borderMagicX, -radiusY), + new Point( radiusX, -borderMagicY), + new Point( radiusX, 0), + new Point( radiusX, borderMagicY), + new Point( borderMagicX, radiusY), + new Point( 0, radiusY), + new Point(-borderMagicX, radiusY), + new Point( -radiusX, borderMagicY), + new Point( -radiusX, 0)}; + + // Future enhancement: Apply the transform to the vertices + // Apply rotation and the shape transform to the control points + Matrix transform = Matrix.Identity; + if (m_rotation != 0) + { + transform.Rotate(m_rotation); + } + + if (_transform.IsIdentity == false) + { + transform *= _transform; + } + + if (transform.IsIdentity == false) + { + for (int i = 0; i < controlPoints.Length; i++) + { + controlPoints[i] = transform.Transform(controlPoints[i]); + } + } + + return controlPoints; + } + + #endregion + } + + /// + /// Class for an elliptical StylusShape + /// + internal sealed class EllipseStylusShape : StylusShape + { + /// + /// Constructor for an elliptical StylusShape + /// + /// + /// + public EllipseStylusShape(double width, double height) + : this(width, height, 0f) + { + } + + /// + /// Constructor for an ellptical StylusShape ,with roation in degree + /// + /// + /// + /// + public EllipseStylusShape(double width, double height, double rotation) + : base(StylusTip.Ellipse, width, height, rotation) + { + } + } + + /// + /// Class for a rectangle StylusShape + /// + internal sealed class RectangleStylusShape : StylusShape + { + /// + /// Constructor + /// + /// + /// + public RectangleStylusShape(double width, double height) + : this(width, height, 0f) + { + } + + /// + /// Constructor with rogation in degree + /// + /// + /// + /// + public RectangleStylusShape(double width, double height, double rotation) + : base(StylusTip.Rectangle, width, height, rotation) + { + } + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/DrawingAttributes.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/DrawingAttributes.cs new file mode 100644 index 0000000..2e99505 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/DrawingAttributes.cs @@ -0,0 +1,979 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.ComponentModel; +using System.Diagnostics; + +using MS.Internal; + +using WpfInk.WindowsBase.System.Windows.Media; + +using SRID = MS.Internal.PresentationCore.SRID; + +namespace WpfInk.PresentationCore.System.Windows.Ink +{ + /// + /// DrawingAttributes is the list of attributes applied to an ink stroke + /// when it is drawn. The DrawingAttributes controls stroke color, width, + /// transparency, and more. + /// + /// + /// Note that when saving the DrawingAttributes, the V1 AntiAlias attribute + /// is always set, and on load the AntiAlias property is ignored. + /// + internal class DrawingAttributes : INotifyPropertyChanged + { + #region Constructors + + /// + /// Creates a DrawingAttributes with default values + /// + public DrawingAttributes() + { + _extendedProperties = new ExtendedPropertyCollection(); + + Initialize(); + } + + /// + /// Common constructor call, also called by Clone + /// + private void Initialize() + { + Debug.Assert(_extendedProperties != null); + _extendedProperties.Changed += + new ExtendedPropertiesChangedEventHandler(this.ExtendedPropertiesChanged_EventForwarder); + } + + #endregion Constructors + + #region Public Properties + + /// + /// The StylusTip used to draw the stroke + /// + public StylusTip StylusTip + { + get + { + //prevent boxing / unboxing if possible + if (!_extendedProperties.Contains(KnownIds.StylusTip)) + { + Debug.Assert(StylusTip.Ellipse == (StylusTip) GetDefaultDrawingAttributeValue(KnownIds.StylusTip)); + return StylusTip.Ellipse; + } + else + { + //if we ever add to StylusTip enumeration, we need to just return GetExtendedPropertyBackedProperty + Debug.Assert(StylusTip.Rectangle == (StylusTip) GetExtendedPropertyBackedProperty(KnownIds.StylusTip)); + return StylusTip.Rectangle; + } + } + set + { + //no need to raise change events, they will bubble up from the EPC + //underneath us + // Validation of value is done in EPC + SetExtendedPropertyBackedProperty(KnownIds.StylusTip, value); + } + } + + /// + /// The StylusTip used to draw the stroke + /// + internal Matrix StylusTipTransform + { + get + { + //prevent boxing / unboxing if possible + if (!_extendedProperties.Contains(KnownIds.StylusTipTransform)) + { + Debug.Assert(Matrix.Identity == (Matrix) GetDefaultDrawingAttributeValue(KnownIds.StylusTipTransform)); + return Matrix.Identity; + } + return (Matrix) GetExtendedPropertyBackedProperty(KnownIds.StylusTipTransform); + } + set + { + Matrix m = (Matrix) value; + if (m.OffsetX != 0 || m.OffsetY != 0) + { + throw new ArgumentException(SR.Get(SRID.InvalidSttValue), "value"); + } + //no need to raise change events, they will bubble up from the EPC + //underneath us + // Validation of value is done in EPC + SetExtendedPropertyBackedProperty(KnownIds.StylusTipTransform, value); + } + } + + /// + /// The height of the StylusTip + /// + public double Height + { + get + { + //prevent boxing / unboxing if possible + if (!_extendedProperties.Contains(KnownIds.StylusHeight)) + { + Debug.Assert(DrawingAttributes.DefaultHeight == (double) GetDefaultDrawingAttributeValue(KnownIds.StylusHeight)); + return DrawingAttributes.DefaultHeight; + } + return (double) GetExtendedPropertyBackedProperty(KnownIds.StylusHeight); + } + set + { + if (double.IsNaN(value) || value < MinHeight || value > MaxHeight) + { + throw new ArgumentOutOfRangeException("Height", SR.Get(SRID.InvalidDrawingAttributesHeight)); + } + //no need to raise change events, they will bubble up from the EPC + //underneath us + SetExtendedPropertyBackedProperty(KnownIds.StylusHeight, value); + } + } + + /// + /// The width of the StylusTip + /// + public double Width + { + get + { + //prevent boxing / unboxing if possible + if (!_extendedProperties.Contains(KnownIds.StylusWidth)) + { + Debug.Assert(DrawingAttributes.DefaultWidth == (double) GetDefaultDrawingAttributeValue(KnownIds.StylusWidth)); + return DrawingAttributes.DefaultWidth; + } + return (double) GetExtendedPropertyBackedProperty(KnownIds.StylusWidth); + } + set + { + if (double.IsNaN(value) || value < MinWidth || value > MaxWidth) + { + throw new ArgumentOutOfRangeException("Width", SR.Get(SRID.InvalidDrawingAttributesWidth)); + } + //no need to raise change events, they will bubble up from the EPC + //underneath us + SetExtendedPropertyBackedProperty(KnownIds.StylusWidth, value); + } + } + + /// + /// When true, ink will be rendered as a series of curves instead of as + /// lines between Stylus sample points. This is useful for smoothing the ink, especially + /// when the person writing the ink has jerky or shaky writing. + /// This value is TRUE by default in the Avalon implementation + /// + public bool FitToCurve + { + get + { + DrawingFlags flags = (DrawingFlags) GetExtendedPropertyBackedProperty(KnownIds.DrawingFlags); + return (0 != (flags & DrawingFlags.FitToCurve)); + } + set + { + //no need to raise change events, they will bubble up from the EPC + //underneath us + DrawingFlags flags = (DrawingFlags) GetExtendedPropertyBackedProperty(KnownIds.DrawingFlags); + if (value) + { + //turn on the bit + flags |= DrawingFlags.FitToCurve; + } + else + { + //turn off the bit + flags &= ~DrawingFlags.FitToCurve; + } + SetExtendedPropertyBackedProperty(KnownIds.DrawingFlags, flags); + } + } + + /// + /// When true, ink will be rendered with any available pressure information + /// taken into account + /// + public bool IgnorePressure + { + get + { + DrawingFlags flags = (DrawingFlags) GetExtendedPropertyBackedProperty(KnownIds.DrawingFlags); + return (0 != (flags & DrawingFlags.IgnorePressure)); + } + set + { + //no need to raise change events, they will bubble up from the EPC + //underneath us + DrawingFlags flags = (DrawingFlags) GetExtendedPropertyBackedProperty(KnownIds.DrawingFlags); + if (value) + { + //turn on the bit + flags |= DrawingFlags.IgnorePressure; + } + else + { + //turn off the bit + flags &= ~DrawingFlags.IgnorePressure; + } + SetExtendedPropertyBackedProperty(KnownIds.DrawingFlags, flags); + } + } + + /// + /// Determines if the stroke should be treated as a highlighter + /// + public bool IsHighlighter + { + get + { + return false; + } + set + { + + } + } + + #region Extended Properties + /// + /// Allows addition of objects to the EPC + /// + /// + /// + public void AddPropertyData(Guid propertyDataId, object propertyData) + { + DrawingAttributes.ValidateStylusTipTransform(propertyDataId, propertyData); + SetExtendedPropertyBackedProperty(propertyDataId, propertyData); + } + + /// + /// Allows removal of objects from the EPC + /// + /// + public void RemovePropertyData(Guid propertyDataId) + { + this.ExtendedProperties.Remove(propertyDataId); + } + + /// + /// Allows retrieval of objects from the EPC + /// + /// + public object GetPropertyData(Guid propertyDataId) + { + return GetExtendedPropertyBackedProperty(propertyDataId); + } + + /// + /// Allows retrieval of a Array of guids that are contained in the EPC + /// + public Guid[] GetPropertyDataIds() + { + return this.ExtendedProperties.GetGuidArray(); + } + + /// + /// Allows check of containment of objects to the EPC + /// + /// + public bool ContainsPropertyData(Guid propertyDataId) + { + return this.ExtendedProperties.Contains(propertyDataId); + } + + /// + /// ExtendedProperties + /// + internal ExtendedPropertyCollection ExtendedProperties + { + get + { + return _extendedProperties; + } + } + + + ///// + ///// Returns a copy of the EPC + ///// + //internal ExtendedPropertyCollection CopyPropertyData() + //{ + // return this.ExtendedProperties.Clone(); + //} + + #endregion + + + + #endregion + + #region Internal Properties + + /// + /// StylusShape + /// + internal StylusShape StylusShape + { + get + { + StylusShape s; + if (this.StylusTip == StylusTip.Rectangle) + { + s = new RectangleStylusShape(this.Width, this.Height); + } + else + { + s = new EllipseStylusShape(this.Width, this.Height); + } + + s.Transform = StylusTipTransform; + return s; + } + } + + /// + /// Sets the Fitting error for this drawing attributes + /// + internal int FittingError + { + get + { + if (!_extendedProperties.Contains(KnownIds.CurveFittingError)) + { + return 0; + } + else + { + return (int) _extendedProperties[KnownIds.CurveFittingError]; + } + } + set + { + _extendedProperties[KnownIds.CurveFittingError] = value; + } + } + + /// + /// Sets the Fitting error for this drawing attributes + /// + internal DrawingFlags DrawingFlags + { + get + { + return (DrawingFlags) GetExtendedPropertyBackedProperty(KnownIds.DrawingFlags); + } + set + { + //no need to raise change events, they will bubble up from the EPC + //underneath us + SetExtendedPropertyBackedProperty(KnownIds.DrawingFlags, value); + } + } + + /// + /// When we load ISF from V1 if width is set and height is not + /// and PenTip is Circle, we need to set height to the same as width + /// or else we'll render different as an Ellipse. We use this flag to + /// preserve state for round tripping. + /// + internal bool HeightChangedForCompatabity + { + get { return _heightChangedForCompatabity; } + set { _heightChangedForCompatabity = value; } + } + + #endregion + + //------------------------------------------------------ + // + // INotifyPropertyChanged Interface + // + //------------------------------------------------------ + + #region INotifyPropertyChanged Interface + + /// + /// INotifyPropertyChanged.PropertyChanged event + /// + event PropertyChangedEventHandler INotifyPropertyChanged.PropertyChanged + { + add { _propertyChanged += value; } + remove { _propertyChanged -= value; } + } + + #endregion INotifyPropertyChanged Interface + + #region Methods + + #region Object overrides + + // What should ExtendedPropertyCollection.GetHashCode return? + /// Retrieve an integer-based value for using ExtendedPropertyCollection + /// objects in a hash table as keys + public override int GetHashCode() + { + return base.GetHashCode(); + } + + /// Overload of the Equals method which determines if two DrawingAttributes + /// objects contain the same drawing attributes + public override bool Equals(object o) + { + if (o == null || o.GetType() != this.GetType()) + { + return false; + } + + //use as and check for null instead of casting to DA to make presharp happy + DrawingAttributes that = o as DrawingAttributes; + if (that == null) + { + return false; + } + + return (this._extendedProperties == that._extendedProperties); + } + + /// Overload of the equality operator which determines + /// if two DrawingAttributes are equal + public static bool operator ==(DrawingAttributes first, DrawingAttributes second) + { + // compare the GC ptrs for the obvious reference equality + if (((object) first == null && (object) second == null) || + ((object) first == (object) second)) + { + return true; + } + // otherwise, if one of the ptrs are null, but not the other then return false + else if ((object) first == null || (object) second == null) + { + return false; + } + // finally use the full `blown value-style comparison against the collection contents + return first.Equals(second); + } + + /// Overload of the not equals operator to determine if two + /// DrawingAttributes are different + public static bool operator !=(DrawingAttributes first, DrawingAttributes second) + { + return !(first == second); + } + #endregion + + ///// + ///// Copies the DrawingAttributes + ///// + ///// Deep copy of the DrawingAttributes + ///// + //public virtual DrawingAttributes Clone() + //{ + // // + // // use MemberwiseClone, which will instance the most derived type + // // We use this instead of Activator.CreateInstance because it does not + // // require ReflectionPermission. One thing to note, all references + // // are shared, including event delegates, so we need to set those to null + // // + // DrawingAttributes clone = (DrawingAttributes) this.MemberwiseClone(); + + // // + // // null the delegates in the cloned DrawingAttributes + // // + // clone.AttributeChanged = null; + // clone.PropertyDataChanged = null; + + // //make a copy of the epc , set up listeners + // clone._extendedProperties = _extendedProperties.Clone(); + // clone.Initialize(); + + // //don't need to clone these, it is a value type + // //and is copied by MemberwiseClone + // //_v1RasterOperation + // //_heightChangedForCompatabity + // return clone; + //} + #endregion + + #region Events + + /// + /// Event fired whenever a DrawingAttribute is modified + /// + public event PropertyDataChangedEventHandler AttributeChanged; + + /// + /// Method called when a change occurs to any DrawingAttribute + /// + /// The change information for the DrawingAttribute that was modified + protected virtual void OnAttributeChanged(PropertyDataChangedEventArgs e) + { + if (null == e) + { + throw new ArgumentNullException("e", SR.Get(SRID.EventArgIsNull)); + } + + try + { + PrivateNotifyPropertyChanged(e); + } + finally + { + if (this.AttributeChanged != null) + { + this.AttributeChanged(this, e); + } + } + } + + /// + /// Event fired whenever a DrawingAttribute is modified + /// + public event PropertyDataChangedEventHandler PropertyDataChanged; + + /// + /// Method called when a change occurs to any PropertyData + /// + /// The change information for the PropertyData that was modified + protected virtual void OnPropertyDataChanged(PropertyDataChangedEventArgs e) + { + if (null == e) + { + throw new ArgumentNullException("e", SR.Get(SRID.EventArgIsNull)); + } + + if (this.PropertyDataChanged != null) + { + this.PropertyDataChanged(this, e); + } + } + + + #endregion + + #region Protected Methods + + /// + /// Method called when a property change occurs to DrawingAttribute + /// + /// The EventArgs specifying the name of the changed property. + protected virtual void OnPropertyChanged(PropertyChangedEventArgs e) + { + if (_propertyChanged != null) + { + _propertyChanged(this, e); + } + } + + #endregion Protected Methods + + #region Private Methods + + /// + /// Simple helper method used to determine if a guid + /// from an ExtendedProperty is used as the backing store + /// of a DrawingAttribute + /// + /// + /// + internal static object GetDefaultDrawingAttributeValue(Guid id) + { + if (KnownIds.Color == id) + { + //return Colors.Black; + } + if (KnownIds.StylusWidth == id) + { + return DrawingAttributes.DefaultWidth; + } + if (KnownIds.StylusTip == id) + { + return StylusTip.Ellipse; + } + if (KnownIds.DrawingFlags == id) + { + //note that in this implementation, FitToCurve is false by default + return DrawingFlags.AntiAliased; + } + if (KnownIds.StylusHeight == id) + { + return DrawingAttributes.DefaultHeight; + } + if (KnownIds.StylusTipTransform == id) + { + return Matrix.Identity; + } + if (KnownIds.IsHighlighter == id) + { + return false; + } + // this is a valid case + // as this helper method is used not only to + // get the default value, but also to see if + // the Guid is a drawing attribute value + return null; + } + + internal static void ValidateStylusTipTransform(Guid propertyDataId, object propertyData) + { + // + // Calling AddPropertyData(KnownIds.StylusTipTransform, "d") does not throw an ArgumentException. + // ExtendedPropertySerializer.Validate take a string as a valid type since StylusTipTransform + // gets serialized as a String, but at runtime is a Matrix + if (propertyData == null) + { + throw new ArgumentNullException("propertyData"); + } + else if (propertyDataId == KnownIds.StylusTipTransform) + { + // StylusTipTransform gets serialized as a String, but at runtime is a Matrix + Type t = propertyData.GetType(); + if (t == typeof(String)) + { + throw new ArgumentException(SR.Get(SRID.InvalidValueType, typeof(Matrix)), "propertyData"); + } + } + } + + /// + /// Simple helper method used to determine if a guid + /// needs to be removed from the ExtendedPropertyCollection in ISF + /// before serializing + /// + /// + /// + internal static bool RemoveIdFromExtendedProperties(Guid id) + { + if (KnownIds.Color == id || + KnownIds.Transparency == id || + KnownIds.StylusWidth == id || + KnownIds.DrawingFlags == id || + KnownIds.StylusHeight == id || + KnownIds.CurveFittingError == id) + { + return true; + } + return false; + } + + /// + /// Returns true if two DrawingAttributes lead to the same PathGeometry. + /// + internal static bool GeometricallyEqual(DrawingAttributes left, DrawingAttributes right) + { + // Optimization case: + // must correspond to the same path geometry if they refer to the same instance. + if (Object.ReferenceEquals(left, right)) + { + return true; + } + + if (left.StylusTip == right.StylusTip && + left.StylusTipTransform == right.StylusTipTransform && + DoubleUtil.AreClose(left.Width, right.Width) && + DoubleUtil.AreClose(left.Height, right.Height) && + left.DrawingFlags == right.DrawingFlags /*contains IgnorePressure / FitToCurve*/) + { + return true; + } + return false; + } + + /// + /// Returns true if the guid passed in has impact on geometry of the stroke + /// + internal static bool IsGeometricalDaGuid(Guid guid) + { + // Assert it is a DA guid + Debug.Assert(null != DrawingAttributes.GetDefaultDrawingAttributeValue(guid)); + + if (guid == KnownIds.StylusHeight || guid == KnownIds.StylusWidth || + guid == KnownIds.StylusTipTransform || guid == KnownIds.StylusTip || + guid == KnownIds.DrawingFlags) + { + return true; + } + + return false; + } + + + + /// + /// Whenever the base class fires the generic ExtendedPropertiesChanged + /// event, we need to fire the DrawingAttributesChanged event also. + /// + /// Should be 'this' object + /// The custom attributes that changed + private void ExtendedPropertiesChanged_EventForwarder(object sender, ExtendedPropertiesChangedEventArgs args) + { + Debug.Assert(sender != null); + Debug.Assert(args != null); + + //see if the EP that changed is a drawingattribute + if (args.NewProperty == null) + { + //a property was removed, see if it is a drawing attribute property + object defaultValueIfDrawingAttribute + = DrawingAttributes.GetDefaultDrawingAttributeValue(args.OldProperty.Id); + if (defaultValueIfDrawingAttribute != null) + { + ExtendedProperty newProperty = + new ExtendedProperty(args.OldProperty.Id, + defaultValueIfDrawingAttribute); + //this is a da guid + PropertyDataChangedEventArgs dargs = + new PropertyDataChangedEventArgs(args.OldProperty.Id, + newProperty.Value, //the property + args.OldProperty.Value);//previous value + + this.OnAttributeChanged(dargs); + } + else + { + PropertyDataChangedEventArgs dargs = + new PropertyDataChangedEventArgs(args.OldProperty.Id, + null, //the property + args.OldProperty.Value);//previous value + + this.OnPropertyDataChanged(dargs); + } + } + else if (args.OldProperty == null) + { + //a property was added, see if it is a drawing attribute property + object defaultValueIfDrawingAttribute + = DrawingAttributes.GetDefaultDrawingAttributeValue(args.NewProperty.Id); + if (defaultValueIfDrawingAttribute != null) + { + if (!defaultValueIfDrawingAttribute.Equals(args.NewProperty.Value)) + { + //this is a da guid + PropertyDataChangedEventArgs dargs = + new PropertyDataChangedEventArgs(args.NewProperty.Id, + args.NewProperty.Value, //the property + defaultValueIfDrawingAttribute); //previous value + + this.OnAttributeChanged(dargs); + } + } + else + { + PropertyDataChangedEventArgs dargs = + new PropertyDataChangedEventArgs(args.NewProperty.Id, + args.NewProperty.Value, //the property + null); //previous value + this.OnPropertyDataChanged(dargs); + } + } + else + { + //something was modified, see if it is a drawing attribute property + object defaultValueIfDrawingAttribute + = DrawingAttributes.GetDefaultDrawingAttributeValue(args.NewProperty.Id); + if (defaultValueIfDrawingAttribute != null) + { + // + // we only raise DA changed when the value actually changes + // + if (!args.NewProperty.Value.Equals(args.OldProperty.Value)) + { + //this is a da guid + PropertyDataChangedEventArgs dargs = + new PropertyDataChangedEventArgs(args.NewProperty.Id, + args.NewProperty.Value, //the da + args.OldProperty.Value);//old value + + this.OnAttributeChanged(dargs); + } + } + else + { + if (!args.NewProperty.Value.Equals(args.OldProperty.Value)) + { + PropertyDataChangedEventArgs dargs = + new PropertyDataChangedEventArgs(args.NewProperty.Id, + args.NewProperty.Value, + args.OldProperty.Value);//old value + + this.OnPropertyDataChanged(dargs); + } + } + } + } + + /// + /// All DrawingAttributes are backed by an ExtendedProperty + /// this is a simple helper to set a property + /// + /// id + /// value + private void SetExtendedPropertyBackedProperty(Guid id, object value) + { + if (_extendedProperties.Contains(id)) + { + // + // check to see if we're setting the property back + // to a default value. If we are we should remove it from + // the EPC + // + object defaultValue = DrawingAttributes.GetDefaultDrawingAttributeValue(id); + if (defaultValue != null) + { + if (defaultValue.Equals(value)) + { + _extendedProperties.Remove(id); + return; + } + } + // + // we're setting a non-default value on a EP that + // already exists, check for equality before we do + // so we don't raise unnecessary EPC changed events + // + object o = GetExtendedPropertyBackedProperty(id); + if (!o.Equals(value)) + { + _extendedProperties[id] = value; + } + } + else + { + // + // make sure we're not setting a default value of the guid + // there is no need to do this + // + object defaultValue = DrawingAttributes.GetDefaultDrawingAttributeValue(id); + if (defaultValue == null || !defaultValue.Equals(value)) + { + _extendedProperties[id] = value; + } + } + } + + /// + /// All DrawingAttributes are backed by an ExtendedProperty + /// this is a simple helper to set a property + /// + /// id + /// + private object GetExtendedPropertyBackedProperty(Guid id) + { + if (!_extendedProperties.Contains(id)) + { + if (null != DrawingAttributes.GetDefaultDrawingAttributeValue(id)) + { + return DrawingAttributes.GetDefaultDrawingAttributeValue(id); + } + throw new ArgumentException(SR.Get(SRID.EPGuidNotFound), "id"); + } + else + { + return _extendedProperties[id]; + } + } + + /// + /// A help method which fires INotifyPropertyChanged.PropertyChanged event + /// + /// + private void PrivateNotifyPropertyChanged(PropertyDataChangedEventArgs e) + { + if (e.PropertyGuid == KnownIds.Color) + { + OnPropertyChanged("Color"); + } + else if (e.PropertyGuid == KnownIds.StylusTip) + { + OnPropertyChanged("StylusTip"); + } + else if (e.PropertyGuid == KnownIds.StylusTipTransform) + { + OnPropertyChanged("StylusTipTransform"); + } + else if (e.PropertyGuid == KnownIds.StylusHeight) + { + OnPropertyChanged("Height"); + } + else if (e.PropertyGuid == KnownIds.StylusWidth) + { + OnPropertyChanged("Width"); + } + else if (e.PropertyGuid == KnownIds.IsHighlighter) + { + OnPropertyChanged("IsHighlighter"); + } + else if (e.PropertyGuid == KnownIds.DrawingFlags) + { + DrawingFlags changedBits = (((DrawingFlags) e.PreviousValue) ^ ((DrawingFlags) e.NewValue)); + + // NOTICE-2006/01/20-WAYNEZEN, + // If someone changes FitToCurve and IgnorePressure simultaneously via AddPropertyData/RemovePropertyData, + // we will fire both OnPropertyChangeds in advance the order of the values. + if ((changedBits & DrawingFlags.FitToCurve) != 0) + { + OnPropertyChanged("FitToCurve"); + } + + if ((changedBits & DrawingFlags.IgnorePressure) != 0) + { + OnPropertyChanged("IgnorePressure"); + } + } + } + + private void OnPropertyChanged(string propertyName) + { + OnPropertyChanged(new PropertyChangedEventArgs(propertyName)); + } + #endregion + + #region Private Fields + + // The private PropertyChanged event + private PropertyChangedEventHandler _propertyChanged; + + private ExtendedPropertyCollection _extendedProperties; + private bool _heightChangedForCompatabity = false; + + /// + /// Statics + /// + internal static readonly float StylusPrecision = 1000.0f; + internal static readonly double DefaultWidth = 2.0031496062992127; + internal static readonly double DefaultHeight = 2.0031496062992127; + + + #endregion + + /// + /// Mininum acceptable stylus tip height, corresponds to 0.001 in V1 + /// + /// corresponds to 0.001 in V1 (0.001 / (2540/96)) + public static readonly double MinHeight = 0.00003779527559055120; + + /// + /// Minimum acceptable stylus tip width + /// + /// corresponds to 0.001 in V1 (0.001 / (2540/96)) + public static readonly double MinWidth = 0.00003779527559055120; + + /// + /// Maximum acceptable stylus tip height. + /// + /// corresponds to 4294967 in V1 (4294967 / (2540/96)) + public static readonly double MaxHeight = 162329.4614173230; + + + /// + /// Maximum acceptable stylus tip width. + /// + /// corresponds to 4294967 in V1 (4294967 / (2540/96)) + public static readonly double MaxWidth = 162329.4614173230; + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/Events.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/Events.cs new file mode 100644 index 0000000..7eb3ca1 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/Events.cs @@ -0,0 +1,287 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Windows.Input; +using WpfInk.PresentationCore.System.Windows.Input.Stylus; +using SRID = MS.Internal.PresentationCore.SRID; + +namespace WpfInk.PresentationCore.System.Windows.Ink +{ + // =========================================================================================== + /// + /// delegate used for event handlers that are called when a stroke was was added, removed, or modified inside of a Stroke collection + /// + internal delegate void StrokeCollectionChangedEventHandler(object sender, StrokeCollectionChangedEventArgs e); + + /// + /// Event arg used when delegate a stroke is was added, removed, or modified inside of a Stroke collection + /// + internal class StrokeCollectionChangedEventArgs : EventArgs + { + private StrokeCollection.ReadOnlyStrokeCollection _added; + private StrokeCollection.ReadOnlyStrokeCollection _removed; + private int _index = -1; + + /// Constructor + internal StrokeCollectionChangedEventArgs(StrokeCollection added, StrokeCollection removed, int index) : + this(added, removed) + { + _index = index; + } + + /// Constructor + public StrokeCollectionChangedEventArgs(StrokeCollection added, StrokeCollection removed) + { + if (added == null && removed == null) + { + throw new ArgumentException(SR.Get(SRID.CannotBothBeNull, "added", "removed")); + } + _added = (added == null) ? null : new StrokeCollection.ReadOnlyStrokeCollection(added); + _removed = (removed == null) ? null : new StrokeCollection.ReadOnlyStrokeCollection(removed); + } + + /// Set of strokes that where added, result may be an empty collection + public StrokeCollection Added + { + get + { + if (_added == null) + { + _added = new StrokeCollection.ReadOnlyStrokeCollection(new StrokeCollection()); + } + return _added; + } + } + + /// Set of strokes that where removed, result may be an empty collection + public StrokeCollection Removed + { + get + { + if (_removed == null) + { + _removed = new StrokeCollection.ReadOnlyStrokeCollection(new StrokeCollection()); + } + return _removed; + } + } + + /// + /// The zero based starting index that was affected + /// + internal int Index + { + get + { + return _index; + } + } + } + + // =========================================================================================== + /// + /// delegate used for event handlers that are called when a change to the drawing attributes associated with one or more strokes has occurred. + /// + internal delegate void PropertyDataChangedEventHandler(object sender, PropertyDataChangedEventArgs e); + + /// + /// Event arg used a change to the drawing attributes associated with one or more strokes has occurred. + /// + internal class PropertyDataChangedEventArgs : EventArgs + { + private Guid _propertyGuid; + private object _newValue; + private object _previousValue; + + /// Constructor + public PropertyDataChangedEventArgs(Guid propertyGuid, + object newValue, + object previousValue) + { + if (newValue == null && previousValue == null) + { + throw new ArgumentException(SR.Get(SRID.CannotBothBeNull, "newValue", "previousValue")); + } + + _propertyGuid = propertyGuid; + _newValue = newValue; + _previousValue = previousValue; + } + + /// + /// Gets the property guid that represents the DrawingAttribute that changed + /// + public Guid PropertyGuid + { + get { return _propertyGuid; } + } + + /// + /// Gets the new value of the DrawingAttribute + /// + public object NewValue + { + get { return _newValue; } + } + + /// + /// Gets the previous value of the DrawingAttribute + /// + public object PreviousValue + { + get { return _previousValue; } + } + } + + + + // =========================================================================================== + /// + /// delegate used for event handlers that are called when the Custom attributes associated with an object have changed. + /// + internal delegate void ExtendedPropertiesChangedEventHandler(object sender, ExtendedPropertiesChangedEventArgs e); + + /// + /// Event Arg used when the Custom attributes associated with an object have changed. + /// + internal class ExtendedPropertiesChangedEventArgs : EventArgs + { + private ExtendedProperty _oldProperty; + private ExtendedProperty _newProperty; + + /// Constructor + internal ExtendedPropertiesChangedEventArgs(ExtendedProperty oldProperty, + ExtendedProperty newProperty) + { + if (oldProperty == null && newProperty == null) + { + throw new ArgumentNullException("oldProperty"); + } + _oldProperty = oldProperty; + _newProperty = newProperty; + } + + /// + /// The value of the previous property. If the Changed event was caused + /// by an ExtendedProperty being added, this value is null + /// + internal ExtendedProperty OldProperty + { + get { return _oldProperty; } + } + + /// + /// The value of the new property. If the Changed event was caused by + /// an ExtendedProperty being removed, this value is null + /// + internal ExtendedProperty NewProperty + { + get { return _newProperty; } + } + } + + /// + /// The delegate to use for the DefaultDrawingAttributesReplaced event + /// + internal delegate void DrawingAttributesReplacedEventHandler(object sender, DrawingAttributesReplacedEventArgs e); + + /// + /// DrawingAttributesReplacedEventArgs + /// + internal class DrawingAttributesReplacedEventArgs : EventArgs + { + /// + /// DrawingAttributesReplacedEventArgs + /// + /// + /// This must be public so InkCanvas can instance it + /// + public DrawingAttributesReplacedEventArgs(DrawingAttributes newDrawingAttributes, DrawingAttributes previousDrawingAttributes) + { + if (newDrawingAttributes == null) + { + throw new ArgumentNullException("newDrawingAttributes"); + } + if (previousDrawingAttributes == null) + { + throw new ArgumentNullException("previousDrawingAttributes"); + } + _newDrawingAttributes = newDrawingAttributes; + _previousDrawingAttributes = previousDrawingAttributes; + } + + /// + /// [TBS] + /// + public DrawingAttributes NewDrawingAttributes + { + get { return _newDrawingAttributes; } + } + + /// + /// [TBS] + /// + public DrawingAttributes PreviousDrawingAttributes + { + get { return _previousDrawingAttributes; } + } + + private DrawingAttributes _newDrawingAttributes; + private DrawingAttributes _previousDrawingAttributes; + } + + /// + /// The delegate to use for the StylusPointsReplaced event + /// + internal delegate void StylusPointsReplacedEventHandler(object sender, StylusPointsReplacedEventArgs e); + + /// + /// StylusPointsReplacedEventArgs + /// + internal class StylusPointsReplacedEventArgs : EventArgs + { + /// + /// StylusPointsReplacedEventArgs + /// + /// + /// This must be public so InkCanvas can instance it + /// + public StylusPointsReplacedEventArgs(StylusPointCollection newStylusPoints, StylusPointCollection previousStylusPoints) + { + if (newStylusPoints == null) + { + throw new ArgumentNullException("newStylusPoints"); + } + if (previousStylusPoints == null) + { + throw new ArgumentNullException("previousStylusPoints"); + } + _newStylusPoints = newStylusPoints; + _previousStylusPoints = previousStylusPoints; + } + + /// + /// [TBS] + /// + public StylusPointCollection NewStylusPoints + { + get { return _newStylusPoints; } + } + + /// + /// [TBS] + /// + public StylusPointCollection PreviousStylusPoints + { + get { return _previousStylusPoints; } + } + + private StylusPointCollection _newStylusPoints; + private StylusPointCollection _previousStylusPoints; + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/Stroke.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/Stroke.cs new file mode 100644 index 0000000..b32d572 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/Stroke.cs @@ -0,0 +1,1126 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; + +using MS.Internal; +using MS.Internal.Ink; +using MS.Internal.Ink.InkSerializedFormat; + +using WpfInk.PresentationCore.System.Windows.Input.Stylus; +using WpfInk.WindowsBase.System.Windows.Media; + +using SRID = MS.Internal.PresentationCore.SRID; + +// Primary root namespace for TabletPC/Ink/Handwriting/Recognition in .NET + +namespace WpfInk.PresentationCore.System.Windows.Ink +{ + /// + /// A Stroke object is the fundamental unit of ink data storage. + /// + internal partial class Stroke + { + /// Create a stroke from a StylusPointCollection + /// + /// + /// StylusPointCollection that makes up the stroke + /// drawingAttributes + public Stroke(StylusPointCollection stylusPoints, DrawingAttributes drawingAttributes) + : this(stylusPoints, drawingAttributes, null) + { + } + + /// Create a stroke from a StylusPointCollection + /// + /// + /// StylusPointCollection that makes up the stroke + /// drawingAttributes + /// extendedProperties + internal Stroke(StylusPointCollection stylusPoints, DrawingAttributes drawingAttributes, ExtendedPropertyCollection extendedProperties) + { + if (stylusPoints == null) + { + throw new ArgumentNullException("stylusPoints"); + } + if (stylusPoints.Count == 0) + { + throw new ArgumentException(SR.Get(SRID.InvalidStylusPointCollectionZeroCount), "stylusPoints"); + } + if (drawingAttributes == null) + { + throw new ArgumentNullException("drawingAttributes"); + } + + _drawingAttributes = drawingAttributes; + _stylusPoints = stylusPoints; + _extendedProperties = extendedProperties; + + Initialize(); + } + + /// + /// Internal helper to set up listeners, called by ctor and by Clone + /// + private void Initialize() + { + _drawingAttributes.AttributeChanged += new PropertyDataChangedEventHandler(DrawingAttributes_Changed); + _stylusPoints.Changed += new EventHandler(StylusPoints_Changed); + // 修复构建 + //_stylusPoints.CountGoingToZero += new CancelEventHandler(StylusPoints_CountGoingToZero); + } + + ///// Returns a new stroke that has a deep copy. + ///// Deep copied data includes points, point description, drawing attributes, and transform + ///// Deep copy of current stroke + //public virtual Stroke Clone() + //{ + // // + // // use MemberwiseClone, which will instance the most derived type + // // We use this instead of Activator.CreateInstance because it does not + // // require ReflectionPermission. One thing to note, all references + // // are shared, including event delegates, so we need to set those to null + // // + // Stroke clone = (Stroke) this.MemberwiseClone(); + + // // + // // null the delegates in the cloned strokes + // // + // clone.DrawingAttributesChanged = null; + // clone.DrawingAttributesReplaced = null; + // clone.StylusPointsReplaced = null; + // clone.StylusPointsChanged = null; + // clone.PropertyDataChanged = null; + // clone.Invalidated = null; + // clone._propertyChanged = null; + + // //Clone is also called from Stroke.Copy internally for point + // //erase. In that case, we don't want to clone the StylusPoints + // //because they will be replaced after we call + // if (_cloneStylusPoints) + // { + // //clone._stylusPoints = _stylusPoints.Clone(); + // throw new NotImplementedException(); + // } + // clone._drawingAttributes = _drawingAttributes.Clone(); + // if (_extendedProperties != null) + // { + // clone._extendedProperties = _extendedProperties.Clone(); + // } + // //set up listeners + // clone.Initialize(); + + // // + // // copy state + // // + // //Debug.Assert(_cachedGeometry == null || _cachedGeometry.IsFrozen); + // //we don't need to cache if this is frozen + // //if (null != _cachedGeometry) + // //{ + // // clone._cachedGeometry = _cachedGeometry.Clone(); + // //} + // //don't need to clone these, they are value types + // //and are copied by MemberwiseClone + // //_isSelected + // //_drawAsHollow + // //_cachedBounds + + // //this need to be reset + // clone._cloneStylusPoints = true; + + // return clone; + //} + + /// + /// Returns a Bezier smoothed version of the StylusPoints + /// + /// + public StylusPointCollection GetBezierStylusPoints() + { + // Since we can't compute Bezier for single point stroke, we should return. + if (_stylusPoints.Count < 2) + { + return _stylusPoints; + } + + // Construct the Bezier approximation + Bezier bezier = new Bezier(); + if (!bezier.ConstructBezierState(_stylusPoints, + DrawingAttributes.FittingError)) + { + //construction failed, return a clone of the original points + return _stylusPoints.Clone(); + } + + double tolerance = 0.5; + StylusShape stylusShape = this.DrawingAttributes.StylusShape; + if (null != stylusShape) + { + Rect shapeBoundingBox = stylusShape.BoundingBox; + double min = Math.Min(shapeBoundingBox.Width, shapeBoundingBox.Height); + tolerance = Math.Log10(min + min); + tolerance *= (StrokeCollectionSerializer.AvalonToHimetricMultiplier / 2); + if (tolerance < 0.5) + { + //don't allow tolerance to drop below .5 or we + //can wind up with an huge amount of bezier points + tolerance = 0.5; + } + } + + List bezierPoints = bezier.Flatten(tolerance); + return GetInterpolatedStylusPoints(bezierPoints); + } + + /// + /// Interpolate packet / pressure data from _stylusPoints + /// + private StylusPointCollection GetInterpolatedStylusPoints(List bezierPoints) + { + Debug.Assert(bezierPoints != null && bezierPoints.Count > 0); + + //new points need the same description + StylusPointCollection bezierStylusPoints = + new StylusPointCollection(bezierPoints.Count); + + // + // add the first point + // + AddInterpolatedBezierPoint(bezierStylusPoints, + bezierPoints[0], + _stylusPoints[0].PressureFactor); + + if (bezierPoints.Count == 1) + { + return bezierStylusPoints; + } + + // + // this is a little tricky... Bezier points are not equidistant, so we have to + // use the length between the points instead of the indexes to interpolate pressure + // + // Bezier points: P0 ------------------------------ P1 ---------- P2 --------- P3 + // Stylus points: P0 -------- P1 ------------ P2 ------------- P3 ---------- P4 + // + // Or in terms of lengths... + // Bezier lengths: L1 ------------------------------ + // L2 --------------------------------------------- + // L3 --------------------------------------------------------- + // + // Stylus lengths L1 -------- + // L2 ------------------------ + // L3 ----------------------------------------- + // L4 -------------------------------------------------------- + // + // + // + double bezierLength = 0.0; + double prevUnbezierLength = 0.0; + double unbezierLength = GetDistanceBetweenPoints((Point) _stylusPoints[0], (Point) _stylusPoints[1]); + + int stylusPointsIndex = 1; + int stylusPointsCount = _stylusPoints.Count; + //skip the first and last point + for (int x = 1; x < bezierPoints.Count - 1; x++) + { + bezierLength += GetDistanceBetweenPoints(bezierPoints[x - 1], bezierPoints[x]); + while (stylusPointsCount > stylusPointsIndex) + { + if (bezierLength >= prevUnbezierLength && + bezierLength < unbezierLength) + { + Debug.Assert(stylusPointsCount > stylusPointsIndex); + + StylusPoint prevStylusPoint = _stylusPoints[stylusPointsIndex - 1]; + float percentFromPrev = + ((float) bezierLength - (float) prevUnbezierLength) / + ((float) unbezierLength - (float) prevUnbezierLength); + float pressureAtPrev = prevStylusPoint.PressureFactor; + float pressureDelta = _stylusPoints[stylusPointsIndex].PressureFactor - pressureAtPrev; + float interopolatedPressure = (percentFromPrev * pressureDelta) + pressureAtPrev; + + AddInterpolatedBezierPoint(bezierStylusPoints, + bezierPoints[x], + interopolatedPressure); + break; + } + else + { + Debug.Assert(bezierLength >= prevUnbezierLength); + // + // move our unbezier lengths forward... + // + stylusPointsIndex++; + if (stylusPointsCount > stylusPointsIndex) + { + prevUnbezierLength = unbezierLength; + unbezierLength += + GetDistanceBetweenPoints((Point) _stylusPoints[stylusPointsIndex - 1], + (Point) _stylusPoints[stylusPointsIndex]); + } //else we'll break + } + } + } + + // + // add the last point + // + AddInterpolatedBezierPoint(bezierStylusPoints, + bezierPoints[bezierPoints.Count - 1], + _stylusPoints[stylusPointsCount - 1].PressureFactor); + + return bezierStylusPoints; + } + + /// + /// Private helper used to get the length between two points + /// + private double GetDistanceBetweenPoints(Point p1, Point p2) + { + Vector spine = p2 - p1; + return Math.Sqrt(spine.LengthSquared); + } + + /// + /// Private helper for adding a StylusPoint to the BezierStylusPoints + /// + private void AddInterpolatedBezierPoint(StylusPointCollection bezierStylusPoints, + Point bezierPoint, + float pressure) + { + double xVal = bezierPoint.X > StylusPoint.MaxXY ? + StylusPoint.MaxXY : + (bezierPoint.X < StylusPoint.MinXY ? StylusPoint.MinXY : bezierPoint.X); + + double yVal = bezierPoint.Y > StylusPoint.MaxXY ? + StylusPoint.MaxXY : + (bezierPoint.Y < StylusPoint.MinXY ? StylusPoint.MinXY : bezierPoint.Y); + + + StylusPoint newBezierPoint = + new StylusPoint(xVal, yVal, pressure); + + + bezierStylusPoints.Add(newBezierPoint); + } + + /// + /// Allows addition of objects to the EPC + /// + /// + /// + public void AddPropertyData(Guid propertyDataId, object propertyData) + { + DrawingAttributes.ValidateStylusTipTransform(propertyDataId, propertyData); + + object oldValue = null; + if (ContainsPropertyData(propertyDataId)) + { + oldValue = GetPropertyData(propertyDataId); + this.ExtendedProperties[propertyDataId] = propertyData; + } + else + { + this.ExtendedProperties.Add(propertyDataId, propertyData); + } + + // fire notification + OnPropertyDataChanged(new PropertyDataChangedEventArgs(propertyDataId, propertyData, oldValue)); + } + + + /// + /// Allows removal of objects from the EPC + /// + /// + public void RemovePropertyData(Guid propertyDataId) + { + object propertyData = GetPropertyData(propertyDataId); + this.ExtendedProperties.Remove(propertyDataId); + // fire notification + OnPropertyDataChanged(new PropertyDataChangedEventArgs(propertyDataId, null, propertyData)); + } + + /// + /// Allows retrieval of objects from the EPC + /// + /// + public object GetPropertyData(Guid propertyDataId) + { + return this.ExtendedProperties[propertyDataId]; + } + + /// + /// Allows retrieval of a Array of guids that are contained in the EPC + /// + public Guid[] GetPropertyDataIds() + { + return this.ExtendedProperties.GetGuidArray(); + } + + /// + /// Allows the checking of objects in the EPC + /// + /// + public bool ContainsPropertyData(Guid propertyDataId) + { + return this.ExtendedProperties.Contains(propertyDataId); + } + + + /// + /// Allows an application to configure the rendering state + /// associated with this stroke (e.g. outline pen, brush, color, + /// stylus tip, etc.) + /// + /// + /// If the stroke has been deleted, this will return null for 'get'. + /// If the stroke has been deleted, the 'set' will no-op. + /// + /// The drawing attributes associated with the current stroke. + public DrawingAttributes DrawingAttributes + { + get + { + return _drawingAttributes; + } + set + { + if (value == null) + { + throw new ArgumentNullException("value"); + } + + _drawingAttributes.AttributeChanged -= new PropertyDataChangedEventHandler(DrawingAttributes_Changed); + + DrawingAttributesReplacedEventArgs e = + new DrawingAttributesReplacedEventArgs(value, _drawingAttributes); + + DrawingAttributes previousDa = _drawingAttributes; + _drawingAttributes = value; + + + // If the drawing attributes change involves Width, Height, StylusTipTransform, IgnorePressure, or FitToCurve, + // we need to force a recaculation of the cached path geometry right after the + // DrawingAttributes changed, beforet the events are raised. + if (false == DrawingAttributes.GeometricallyEqual(previousDa, _drawingAttributes)) + { + //_cachedGeometry = null; + // Set the cached bounds to empty, which will force a re-calculation of the _cachedBounds upon next GetBounds call. + _cachedBounds = Rect.Empty; + } + + _drawingAttributes.AttributeChanged += new PropertyDataChangedEventHandler(DrawingAttributes_Changed); + OnDrawingAttributesReplaced(e); + OnInvalidated(EventArgs.Empty); + OnPropertyChanged(DrawingAttributesName); + } + } + + /// + /// StylusPoints + /// + public StylusPointCollection StylusPoints + { + get + { + return _stylusPoints; + } + set + { + if (null == value) + { + throw new ArgumentNullException("value"); + } + if (value.Count == 0) + { + //we don't allow this + throw new ArgumentException(SR.Get(SRID.InvalidStylusPointCollectionZeroCount)); + } + + // Force a recaculation of the cached path geometry + //_cachedGeometry = null; + + // Set the cached bounds to empty, which will force a re-calculation of the _cachedBounds upon next GetBounds call. + _cachedBounds = Rect.Empty; + + StylusPointsReplacedEventArgs e = + new StylusPointsReplacedEventArgs(value, _stylusPoints); + + _stylusPoints.Changed -= new EventHandler(StylusPoints_Changed); + // 修复构建 + //_stylusPoints.CountGoingToZero -= new CancelEventHandler(StylusPoints_CountGoingToZero); + + _stylusPoints = value; + + _stylusPoints.Changed += new EventHandler(StylusPoints_Changed); + // 修复构建 + //_stylusPoints.CountGoingToZero += new CancelEventHandler(StylusPoints_CountGoingToZero); + + // fire notification + OnStylusPointsReplaced(e); + OnInvalidated(EventArgs.Empty); + OnPropertyChanged(StylusPointsName); + } + } + + /// Event that is fired when a drawing attribute is changed. + /// The event listener to add or remove in the listener chain + public event PropertyDataChangedEventHandler DrawingAttributesChanged; + + /// + /// Event that is fired when the DrawingAttributes have been replaced + /// + public event DrawingAttributesReplacedEventHandler DrawingAttributesReplaced; + + /// + /// Notifies listeners whenever the StylusPoints have been replaced + /// + public event StylusPointsReplacedEventHandler StylusPointsReplaced; + + /// + /// Notifies listeners whenever the StylusPoints have been changed + /// + public event EventHandler StylusPointsChanged; + + /// + /// Notifies listeners whenever a change occurs in the propertyData + /// + /// PropertyDataChangedEventHandler + public event PropertyDataChangedEventHandler PropertyDataChanged; + + + /// + /// Stroke would raise this event for PacketsChanged, DrawingAttributeChanged, or DrawingAttributeReplaced. + /// Renderer would simply listen to this. Stroke developer can raise this event by calling OnInvalidated when + /// he wants the renderer to repaint. + /// + public event EventHandler Invalidated; + + /// + /// INotifyPropertyChanged.PropertyChanged event, explicitly implemented + /// + event PropertyChangedEventHandler INotifyPropertyChanged.PropertyChanged + { + add { _propertyChanged += value; } + remove { _propertyChanged -= value; } + } + + /// + /// Method called on derived classes whenever a drawing attribute + /// is changed and event listeners must be notified. + /// + /// Information on the drawing attributes that changed + /// Derived classes should call this method (their base class) + /// to ensure that event listeners are notified + protected virtual void OnDrawingAttributesChanged(PropertyDataChangedEventArgs e) + { + if (null == e) + { + throw new ArgumentNullException("e", SR.Get(SRID.EventArgIsNull)); + } + + if (DrawingAttributesChanged != null) + { + DrawingAttributesChanged(this, e); + } + } + + /// + /// Protected virtual version for developers deriving from InkCanvas. + /// This method is what actually throws the event. + /// + /// DrawingAttributesReplacedEventArgs to raise the event with + protected virtual void OnDrawingAttributesReplaced(DrawingAttributesReplacedEventArgs e) + { + if (e == null) + { + throw new ArgumentNullException("e"); + } + if (null != this.DrawingAttributesReplaced) + { + DrawingAttributesReplaced(this, e); + } + } + + /// + /// Method called on derived classes whenever the StylusPoints are replaced + /// + /// EventArgs + protected virtual void OnStylusPointsReplaced(StylusPointsReplacedEventArgs e) + { + if (null == e) + { + throw new ArgumentNullException("e", SR.Get(SRID.EventArgIsNull)); + } + + if (StylusPointsReplaced != null) + StylusPointsReplaced(this, e); + } + + /// + /// Method called on derived classes whenever the StylusPoints are changed + /// + /// EventArgs + protected virtual void OnStylusPointsChanged(EventArgs e) + { + if (null == e) + { + throw new ArgumentNullException("e", SR.Get(SRID.EventArgIsNull)); + } + + if (StylusPointsChanged != null) + StylusPointsChanged(this, e); + } + + /// + /// Method called on derived classes whenever a change occurs in + /// the PropertyData. + /// + /// Derived classes should call this method (their base class) + /// to ensure that event listeners are notified + protected virtual void OnPropertyDataChanged(PropertyDataChangedEventArgs e) + { + if (null == e) + { + throw new ArgumentNullException("e", SR.Get(SRID.EventArgIsNull)); + } + + if (PropertyDataChanged != null) + { + PropertyDataChanged(this, e); + } + } + + + /// + /// Method called on derived classes whenever a stroke needs repaint. Developers who + /// subclass Stroke and need a repaint could raise Invalidated through this protected virtual + /// + protected virtual void OnInvalidated(EventArgs e) + { + if (null == e) + { + throw new ArgumentNullException("e", SR.Get(SRID.EventArgIsNull)); + } + + if (Invalidated != null) + { + Invalidated(this, e); + } + } + + /// + /// Method called when a property change occurs to the Stroke + /// + /// The EventArgs specifying the name of the changed property. + /// To follow the guidelines, this method should take a PropertyChangedEventArgs + /// instance, but every other INotifyPropertyChanged implementation follows this pattern. + protected virtual void OnPropertyChanged(PropertyChangedEventArgs e) + { + if (_propertyChanged != null) + { + _propertyChanged(this, e); + } + } + + + /// + /// ExtendedProperties + /// + internal ExtendedPropertyCollection ExtendedProperties + { + get + { + if (_extendedProperties == null) + { + _extendedProperties = new ExtendedPropertyCollection(); + } + + return _extendedProperties; + } + } + + +// /// +// /// Clip +// /// +// /// Fragment markers for clipping +// private StrokeCollection Clip(StrokeFIndices[] cutAt) +// { +// Debug.Assert(cutAt != null); +// Debug.Assert(cutAt.Length != 0); + +//#if DEBUG +// // +// // Assert there are no overlaps between multiple StrokeFIndices +// // +// AssertSortedNoOverlap(cutAt); +//#endif + +// StrokeCollection leftovers = new StrokeCollection(); +// if (cutAt.Length == 0) +// { +// return leftovers; +// } + +// if ((cutAt.Length == 1) && cutAt[0].IsFull) +// { +// leftovers.Add(this.Clone()); //clip and erase always return clones +// return leftovers; +// } + + +// StylusPointCollection sourceStylusPoints = this.StylusPoints; +// if (this.DrawingAttributes.FitToCurve) +// { +// sourceStylusPoints = this.GetBezierStylusPoints(); +// } + +// // +// // Assert the findices are NOT out of range with the packets +// // +// Debug.Assert(false == ((!DoubleUtil.AreClose(cutAt[cutAt.Length - 1].EndFIndex, StrokeFIndices.AfterLast)) && +// Math.Ceiling(cutAt[cutAt.Length - 1].EndFIndex) > sourceStylusPoints.Count - 1)); + +// for (int i = 0; i < cutAt.Length; i++) +// { +// StrokeFIndices fragment = cutAt[i]; +// if (DoubleUtil.GreaterThanOrClose(fragment.BeginFIndex, fragment.EndFIndex)) +// { +// // ISSUE-2004/06/26-vsmirnov - temporary workaround for bugs +// // in point erasing: drop invalid fragments +// Debug.Assert(DoubleUtil.LessThan(fragment.BeginFIndex, fragment.EndFIndex)); +// continue; +// } + +// Stroke stroke = Copy(sourceStylusPoints, fragment.BeginFIndex, fragment.EndFIndex); + +// // Add the stroke to the output collection +// leftovers.Add(stroke); +// } + +// return leftovers; +// } + +// /// +// /// +// /// +// /// Fragment markers for clipping +// /// Survived fragments of current Stroke as a StrokeCollection +// private StrokeCollection Erase(StrokeFIndices[] cutAt) +// { +// Debug.Assert(cutAt != null); +// Debug.Assert(cutAt.Length != 0); + +//#if DEBUG +// // +// // Assert there are no overlaps between multiple StrokeFIndices +// // +// AssertSortedNoOverlap(cutAt); +//#endif + +// StrokeCollection leftovers = new StrokeCollection(); +// // Return an empty collection if the entire stroke it to erase +// if ((cutAt.Length == 0) || ((cutAt.Length == 1) && cutAt[0].IsFull)) +// { +// return leftovers; +// } + +// StylusPointCollection sourceStylusPoints = this.StylusPoints; +// if (this.DrawingAttributes.FitToCurve) +// { +// sourceStylusPoints = this.GetBezierStylusPoints(); +// } + +// // +// // Assert the findices are NOT out of range with the packets +// // +// Debug.Assert(false == ((!DoubleUtil.AreClose(cutAt[cutAt.Length - 1].EndFIndex, StrokeFIndices.AfterLast)) && +// Math.Ceiling(cutAt[cutAt.Length - 1].EndFIndex) > sourceStylusPoints.Count - 1)); + + +// int i = 0; +// double beginFIndex = StrokeFIndices.BeforeFirst; +// if (cutAt[0].BeginFIndex == StrokeFIndices.BeforeFirst) +// { +// beginFIndex = cutAt[0].EndFIndex; +// i++; +// } +// for (; i < cutAt.Length; i++) +// { +// StrokeFIndices fragment = cutAt[i]; +// if (DoubleUtil.GreaterThanOrClose(beginFIndex, fragment.BeginFIndex)) +// { +// // ISSUE-2004/06/26-vsmirnov - temporary workaround for bugs +// // in point erasing: drop invalid fragments +// Debug.Assert(DoubleUtil.LessThan(beginFIndex, fragment.BeginFIndex)); +// continue; +// } + + +// Stroke stroke = Copy(sourceStylusPoints, beginFIndex, fragment.BeginFIndex); +// // Add the stroke to the output collection +// leftovers.Add(stroke); + +// beginFIndex = fragment.EndFIndex; +// } + +// if (beginFIndex != StrokeFIndices.AfterLast) +// { +// Stroke stroke = Copy(sourceStylusPoints, beginFIndex, StrokeFIndices.AfterLast); + +// // Add the stroke to the output collection +// leftovers.Add(stroke); +// } + +// return leftovers; +// } + + + ///// + ///// Creates a new stroke from a subset of the points + ///// + //private Stroke Copy(StylusPointCollection sourceStylusPoints, double beginFIndex, double endFIndex) + //{ + // Debug.Assert(sourceStylusPoints != null); + // // + // // get the floor and ceiling to copy from, we'll adjust the ends below + // // + // int beginIndex = + // (DoubleUtil.AreClose(StrokeFIndices.BeforeFirst, beginFIndex)) + // ? 0 : (int) Math.Floor(beginFIndex); + + // int endIndex = + // (DoubleUtil.AreClose(StrokeFIndices.AfterLast, endFIndex)) + // ? (sourceStylusPoints.Count - 1) : (int) Math.Ceiling(endFIndex); + + // int pointCount = endIndex - beginIndex + 1; + // Debug.Assert(pointCount >= 1); + + // StylusPointCollection stylusPoints = + // new StylusPointCollection(pointCount); + + // // + // // copy the data from the floor of beginIndex to the ceiling + // // + // for (int i = 0; i < pointCount; i++) + // { + // Debug.Assert(sourceStylusPoints.Count > i + beginIndex); + // StylusPoint stylusPoint = sourceStylusPoints[i + beginIndex]; + // stylusPoints.Add(stylusPoint); + // } + // Debug.Assert(stylusPoints.Count == pointCount); + + // // + // // at this point, the stroke has been reduced to one with n number of points + // // so we need to adjust the fIndices based on the new point data + // // + // // for example, in a stroke with 4 points: + // // 0, 1, 2, 3 + // // + // // if the fIndexes passed 1.1 and 2.7 + // // at this point beginIndex is 1 and endIndex is 3 + // // + // // now that we've copied the stroke points 1, 2 and 3, we need to + // // adjust beginFIndex to .1 and endFIndex to 1.7 + // // + // if (!DoubleUtil.AreClose(beginFIndex, StrokeFIndices.BeforeFirst)) + // { + // beginFIndex = beginFIndex - beginIndex; + // } + // if (!DoubleUtil.AreClose(endFIndex, StrokeFIndices.AfterLast)) + // { + // endFIndex = endFIndex - beginIndex; + // } + + // if (stylusPoints.Count > 1) + // { + // Point begPoint = (Point) stylusPoints[0]; + // Point endPoint = (Point) stylusPoints[stylusPoints.Count - 1]; + + // // Adjust the last point to fragment.EndFIndex. + // if ((!DoubleUtil.AreClose(endFIndex, StrokeFIndices.AfterLast)) && !DoubleUtil.AreClose(endIndex, endFIndex)) + // { + // // + // // for 1.7, we need to get .3, because that is the distance + // // we need to back up between the third point and the second + // // + // // so this would be .3 = 2 - 1.7 + // double ceiling = Math.Ceiling(endFIndex); + // double fraction = ceiling - endFIndex; + + // endPoint = GetIntermediatePoint(stylusPoints[stylusPoints.Count - 1], + // stylusPoints[stylusPoints.Count - 2], + // fraction); + // } + + // // Adjust the first point to fragment.BeginFIndex. + // if ((!DoubleUtil.AreClose(beginFIndex, StrokeFIndices.BeforeFirst)) && !DoubleUtil.AreClose(beginIndex, beginFIndex)) + // { + // begPoint = GetIntermediatePoint(stylusPoints[0], + // stylusPoints[1], + // beginFIndex); + // } + + // // + // // now set the end points + // // + // StylusPoint tempEnd = stylusPoints[stylusPoints.Count - 1]; + // tempEnd.X = endPoint.X; + // tempEnd.Y = endPoint.Y; + // stylusPoints[stylusPoints.Count - 1] = tempEnd; + + // StylusPoint tempBegin = stylusPoints[0]; + // tempBegin.X = begPoint.X; + // tempBegin.Y = begPoint.Y; + // stylusPoints[0] = tempBegin; + // } + + // Stroke stroke = null; + // try + // { + // // + // // set a flag that tells clone not to clone the StylusPoints + // // we do this in a try finally so we alway reset our state + // // even if Clone (which is virtual) throws + // // + // _cloneStylusPoints = false; + // stroke = this.Clone(); + // if (stroke.DrawingAttributes.FitToCurve) + // { + // // + // // we're using the beziered points for the new data, + // // FitToCurve needs to be false to prevent re-bezier. + // // + // stroke.DrawingAttributes.FitToCurve = false; + // } + + // //this will reset the cachedGeometry and cachedBounds + // stroke.StylusPoints = stylusPoints; + // } + // finally + // { + // _cloneStylusPoints = true; + // } + + // return stroke; + //} + + /// + /// Private helper that will generate a new point between two points at an findex + /// + private Point GetIntermediatePoint(StylusPoint p1, StylusPoint p2, double findex) + { + double xDistance = p2.X - p1.X; + double yDistance = p2.Y - p1.Y; + + double xFDistance = xDistance * findex; + double yFDistance = yDistance * findex; + + return new Point(p1.X + xFDistance, p1.Y + yFDistance); + } + + +#if DEBUG + /// + /// Helper method used to validate that the strokefindices in the array + /// are sorted and there are no overlaps + /// + /// fragments + private void AssertSortedNoOverlap(StrokeFIndices[] fragments) + { + if (fragments.Length == 0) + { + return; + } + if (fragments.Length == 1) + { + Debug.Assert(IsValidStrokeFIndices(fragments[0])); + return; + } + double current = StrokeFIndices.BeforeFirst; + for (int x = 0; x < fragments.Length; x++) + { + if (fragments[x].BeginFIndex <= current) + { + // + // when x == 0, we're just starting, any value is valid + // + Debug.Assert(x == 0); + } + current = fragments[x].BeginFIndex; + Debug.Assert(IsValidStrokeFIndices(fragments[x]) && fragments[x].EndFIndex > current); + current = fragments[x].EndFIndex; + } + } + + private bool IsValidStrokeFIndices(StrokeFIndices findex) + { + return (!double.IsNaN(findex.BeginFIndex) && !double.IsNaN(findex.EndFIndex) && findex.BeginFIndex < findex.EndFIndex); + } + +#endif + + + /// + /// Method called whenever the Stroke's drawing attributes are changed. + /// This method will trigger an event for any listeners interested in + /// drawing attributes. + /// + /// The Drawing Attributes object that was changed + /// More data about the change that occurred + private void DrawingAttributes_Changed(object sender, PropertyDataChangedEventArgs e) + { + // set Geometry flag to be dirty if the DA change will cause change in geometry + if (DrawingAttributes.IsGeometricalDaGuid(e.PropertyGuid) == true) + { + //_cachedGeometry = null; + // Set the cached bounds to empty, which will force a re-calculation of the _cachedBounds upon next GetBounds call. + _cachedBounds = Rect.Empty; + } + + OnDrawingAttributesChanged(e); + if (!_delayRaiseInvalidated) + { + //when Stroke.Transform(Matrix, bool) is called, we don't raise invalidated from + //here, but rather from the Stroke.Transform method. + OnInvalidated(EventArgs.Empty); + } + } + + /// + /// Method called whenever the Stroke's StylusPoints are changed. + /// This method will trigger an event for any listeners interested in + /// Invalidate + /// + /// The StylusPoints object that was changed + /// event args + private void StylusPoints_Changed(object sender, EventArgs e) + { + //_cachedGeometry = null; + _cachedBounds = Rect.Empty; + + OnStylusPointsChanged(EventArgs.Empty); + if (!_delayRaiseInvalidated) + { + //when Stroke.Transform(Matrix, bool) is called, we don't raise invalidated from + //here, but rather from the Stroke.Transform method. + OnInvalidated(EventArgs.Empty); + } + } + + /// + /// Private method called when StylusPoints are going to zero + /// + /// The StylusPoints object that is about to go to zero count + /// event args + private void StylusPoints_CountGoingToZero(object sender, CancelEventArgs e) + { + e.Cancel = true; + //StylusPoints will raise the exception + } + + private void OnPropertyChanged(string propertyName) + { + OnPropertyChanged(new PropertyChangedEventArgs(propertyName)); + } + + // Custom attributes associated with this stroke + private ExtendedPropertyCollection _extendedProperties = null; + + // Drawing attributes associated with this stroke + private DrawingAttributes _drawingAttributes = null; + + private StylusPointCollection _stylusPoints = null; + } + + //internal helper to determine if a matix contains invalid values + internal static class MatrixHelper + { + //returns true if any member is NaN + internal static bool ContainsNaN(Matrix matrix) + { + if (Double.IsNaN(matrix.M11) || + Double.IsNaN(matrix.M12) || + Double.IsNaN(matrix.M21) || + Double.IsNaN(matrix.M22) || + Double.IsNaN(matrix.OffsetX) || + Double.IsNaN(matrix.OffsetY)) + { + return true; + } + return false; + } + + //returns true if any member is negative or positive infinity + internal static bool ContainsInfinity(Matrix matrix) + { + if (Double.IsInfinity(matrix.M11) || + Double.IsInfinity(matrix.M12) || + Double.IsInfinity(matrix.M21) || + Double.IsInfinity(matrix.M22) || + Double.IsInfinity(matrix.OffsetX) || + Double.IsInfinity(matrix.OffsetY)) + { + return true; + } + return false; + } + } + + /// + /// Helper for dealing with IEnumerable of Points + /// + internal static class IEnumerablePointHelper + { + /// + /// Returns the count of an IEumerable of Points by trying to cast + /// to an ICollection of Points + /// + internal static int GetCount(IEnumerable ienum) + { + Debug.Assert(ienum != null); + ICollection icol = ienum as ICollection; + if (icol != null) + { + return icol.Count; + } + int count = 0; + foreach (Point point in ienum) + { + count++; + } + return count; + } + + /// + /// Returns a Point[] for a given IEnumerable of Points. + /// + internal static Point[] GetPointArray(IEnumerable ienum) + { + Debug.Assert(ienum != null); + Point[] points = ienum as Point[]; + if (points != null) + { + return points; + } + + // + // fall back to creating an array + // + points = new Point[GetCount(ienum)]; + int index = 0; + foreach (Point point in ienum) + { + points[index++] = point; + } + return points; + } + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/Stroke2.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/Stroke2.cs new file mode 100644 index 0000000..fc1f773 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/Stroke2.cs @@ -0,0 +1,163 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +//#define DEBUG_RENDERING_FEEDBACK + +using System; +using System.ComponentModel; + +// Primary root namespace for TabletPC/Ink/Handwriting/Recognition in .NET + +namespace WpfInk.PresentationCore.System.Windows.Ink +{ + /// + /// The hit-testing API of Stroke + /// + partial class Stroke : INotifyPropertyChanged + { + #region Public APIs + + + #region Public Methods +#if false + + /// + /// Computes the bounds of the stroke in the default rendering context + /// + /// + public virtual Rect GetBounds() + { + if (_cachedBounds.IsEmpty) + { + StrokeNodeIterator iterator = StrokeNodeIterator.GetIterator(this, this.DrawingAttributes); + for (int i = 0; i < iterator.Count; i++) + { + StrokeNode strokeNode = iterator[i]; + _cachedBounds.Union(strokeNode.GetBounds()); + } + } + + return _cachedBounds; + } + + + /// + /// Render the Stroke under the specified DrawingContext. The draw method is a + /// batch operationg that uses the rendering methods exposed off of DrawingContext + /// + /// + public void Draw(DrawingContext context) + { + if (null == context) + { + throw new System.ArgumentNullException("context"); + } + + //our code never calls this public API so we can assume that opacity + //has not been set up + + //call our public Draw method with the strokes.DA + this.Draw(context, this.DrawingAttributes); + } + + + /// + /// Render the StrokeCollection under the specified DrawingContext. This draw method uses the + /// passing in drawing attribute to override that on the stroke. + /// + /// + /// + public void Draw(DrawingContext drawingContext, DrawingAttributes drawingAttributes) + { + if (null == drawingContext) + { + throw new System.ArgumentNullException("context"); + } + + if (null == drawingAttributes) + { + throw new System.ArgumentNullException("drawingAttributes"); + } + + // context.VerifyAccess(); + + //our code never calls this public API so we can assume that opacity + //has not been set up + + if (drawingAttributes.IsHighlighter) + { + drawingContext.PushOpacity(StrokeRenderer.HighlighterOpacity); + try + { + this.DrawInternal(drawingContext, StrokeRenderer.GetHighlighterAttributes(this, this.DrawingAttributes), false); + } + finally + { + drawingContext.Pop(); + } + } + else + { + this.DrawInternal(drawingContext, drawingAttributes, false); + } + } + +#endif + + + + + + + #endregion + + #endregion + + + #region Internal APIs + + + + + /// + /// Used by Inkcanvas to draw selected stroke as hollow. + /// + internal bool IsSelected + { + get { return _isSelected; } + set + { + if (value != _isSelected) + { + _isSelected = value; + + // Raise Invalidated event. This will cause Renderer to repaint and call back DrawCore + OnInvalidated(EventArgs.Empty); + } + } + } + + + #region Private fields + + + private bool _isSelected = false; + private bool _drawAsHollow = false; + private bool _cloneStylusPoints = true; + private bool _delayRaiseInvalidated = false; + private static readonly double HollowLineSize = 1.0f; + private Rect _cachedBounds = Rect.Empty; + + // The private PropertyChanged event + private PropertyChangedEventHandler _propertyChanged; + + private const string DrawingAttributesName = "DrawingAttributes"; + private const string StylusPointsName = "StylusPoints"; + + #endregion + + internal static readonly double PercentageTolerance = 0.0001d; + #endregion + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/StrokeCollection.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/StrokeCollection.cs new file mode 100644 index 0000000..88fdb12 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/StrokeCollection.cs @@ -0,0 +1,708 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics; + +using SRID = MS.Internal.PresentationCore.SRID; + +// Primary root namespace for TabletPC/Ink/Handwriting/Recognition in .NET + +namespace WpfInk.PresentationCore.System.Windows.Ink +{ + /// + /// Collection of strokes objects which can be operated on in aggregate. + /// + internal partial class StrokeCollection + { + /// + /// The string used to designate the native persistence format + /// for ink data (e.g. used on the clipboard) + /// + public static readonly String InkSerializedFormat = "Ink Serialized Format"; + + /// Creates an empty stroke collection + public StrokeCollection() + { + } + + /// Creates a StrokeCollection based on a collection of existing strokes + public StrokeCollection(IEnumerable strokes) + { + if (strokes == null) + { + throw new ArgumentNullException("strokes"); + } + + List items = (List) this.Items; + + //unfortunately we have to check for dupes with this ctor + foreach (Stroke stroke in strokes) + { + if (items.Contains(stroke)) + { + //clear and throw + items.Clear(); + throw new ArgumentException(SR.Get(SRID.StrokeIsDuplicated), "strokes"); + } + items.Add(stroke); + } + } + + +// /// +// /// Performs a deep copy of the StrokeCollection. +// /// +// public virtual StrokeCollection Clone() +// { +// StrokeCollection clone = new StrokeCollection(); +// foreach (Stroke s in this) +// { +// // samgeo - Presharp issue +// // Presharp gives a warning when get methods might deref a null. It's complaining +// // here that s could be null, but StrokeCollection never allows nulls to be added +// // so this is not possible +//#pragma warning disable 1634, 1691 +//#pragma warning suppress 6506 +// clone.Add(s.Clone()); +//#pragma warning restore 1634, 1691 +// } + +// // +// // clone epc if we have them +// // +// if (_extendedProperties != null) +// { +// clone._extendedProperties = _extendedProperties.Clone(); +// } +// return clone; +// } + + /// + /// called by base class Collection<T> when the list is being cleared; + /// raises a CollectionChanged event to any listeners + /// + protected override sealed void ClearItems() + { + if (this.Count > 0) + { + StrokeCollection removed = new StrokeCollection(); + for (int x = 0; x < this.Count; x++) + { + ((List) removed.Items).Add(this[x]); + } + + base.ClearItems(); + + RaiseStrokesChanged(null /*added*/, removed, -1); + } + } + + /// + /// called by base class RemoveAt or Remove methods + /// + protected override sealed void RemoveItem(int index) + { + Stroke removedStroke = this[index]; + base.RemoveItem(index); + + StrokeCollection removed = new StrokeCollection(); + ((List) removed.Items).Add(removedStroke); + RaiseStrokesChanged(null /*added*/, removed, index); + } + + /// + /// called by base class Insert, Add methods + /// + protected override sealed void InsertItem(int index, Stroke stroke) + { + if (stroke == null) + { + throw new ArgumentNullException("stroke"); + } + if (this.IndexOf(stroke) != -1) + { + throw new ArgumentException(SR.Get(SRID.StrokeIsDuplicated), "stroke"); + } + + base.InsertItem(index, stroke); + + StrokeCollection addedStrokes = new StrokeCollection(); + ((List) addedStrokes.Items).Add(stroke); + RaiseStrokesChanged(addedStrokes, null /*removed*/, index); + } + + /// + /// called by base class set_Item method + /// + protected override sealed void SetItem(int index, Stroke stroke) + { + if (stroke == null) + { + throw new ArgumentNullException("stroke"); + } + if (IndexOf(stroke) != -1) + { + throw new ArgumentException(SR.Get(SRID.StrokeIsDuplicated), "stroke"); + } + + Stroke removedStroke = this[index]; + base.SetItem(index, stroke); + + StrokeCollection removed = new StrokeCollection(); + ((List) removed.Items).Add(removedStroke); + + StrokeCollection added = new StrokeCollection(); + ((List) added.Items).Add(stroke); + RaiseStrokesChanged(added, removed, index); + } + + /// + /// Gets the index of the stroke, or -1 if it is not found + /// + /// stroke + /// + public new int IndexOf(Stroke stroke) + { + if (stroke == null) + { + //we never allow null strokes + return -1; + } + for (int i = 0; i < Count; i++) + { + if (object.ReferenceEquals(this[i], stroke)) + { + return i; + } + } + return -1; + } + + /// + /// Remove a set of Stroke objects to the collection + /// + /// The strokes to remove from the collection + /// Changes to the collection trigger a StrokesChanged event. + public void Remove(StrokeCollection strokes) + { + if (strokes == null) + { + throw new ArgumentNullException("strokes"); + } + if (strokes.Count == 0) + { + // NOTICE-2004/06/08-WAYNEZEN: + // We don't throw if an empty collection is going to be removed. And there is no event either. + // This rule is also applied to invoking Clear() with an empty StrokeCollection. + return; + } + + int[] indexes = this.GetStrokeIndexes(strokes); + if (indexes == null) + { + // At least one stroke doesn't exist in our collection. We throw. + ArgumentException ae = new ArgumentException(SR.Get(SRID.InvalidRemovedStroke), "strokes"); + // + // we add a tag here so we can check for this in EraserBehavior.OnPointEraseResultChanged + // to determine if this method is the origin of an ArgumentException we harden against + // + ae.Data.Add("System.Windows.Ink.StrokeCollection", ""); + throw ae; + } + + for (int x = indexes.Length - 1; x >= 0; x--) + { + //bypass this.RemoveAt, which calls changed events + //and call our protected List directly + //remove from the back so the indexes are correct + ((List) this.Items).RemoveAt(indexes[x]); + } + + RaiseStrokesChanged(null /*added*/, strokes, indexes[0]); + } + + /// + /// Add a set of Stroke objects to the collection + /// + /// The strokes to add to the collection + /// The items are added to the collection at the end of the list. + /// If the item already exists in the collection, then the item is not added again. + public void Add(StrokeCollection strokes) + { + if (strokes == null) + { + throw new ArgumentNullException("strokes"); + } + if (strokes.Count == 0) + { + // NOTICE-2004/06/08-WAYNEZEN: + // We don't throw if an empty collection is going to be added. And there is no event either. + return; + } + + int index = this.Count; + + //validate that none of the strokes exist in the collection + for (int x = 0; x < strokes.Count; x++) + { + Stroke stroke = strokes[x]; + if (this.IndexOf(stroke) != -1) + { + throw new ArgumentException(SR.Get(SRID.StrokeIsDuplicated), "strokes"); + } + } + + //add the strokes + //bypass this.AddRange, which calls changed events + //and call our protected List directly + ((List) this.Items).AddRange(strokes); + + RaiseStrokesChanged(strokes, null /*removed*/, index); + } + + /// + /// Replace + /// + /// + /// + public void Replace(Stroke strokeToReplace, StrokeCollection strokesToReplaceWith) + { + if (strokeToReplace == null) + { + throw new ArgumentNullException(SR.Get(SRID.EmptyScToReplace)); + } + + StrokeCollection strokesToReplace = new StrokeCollection(); + strokesToReplace.Add(strokeToReplace); + this.Replace(strokesToReplace, strokesToReplaceWith); + } + + /// + /// Replace + /// + /// + /// + public void Replace(StrokeCollection strokesToReplace, StrokeCollection strokesToReplaceWith) + { + if (strokesToReplace == null) + { + throw new ArgumentNullException(SR.Get(SRID.EmptyScToReplace)); + } + if (strokesToReplaceWith == null) + { + throw new ArgumentNullException(SR.Get(SRID.EmptyScToReplaceWith)); + } + + int replaceCount = strokesToReplace.Count; + if (replaceCount == 0) + { + ArgumentException ae = new ArgumentException(SR.Get(SRID.EmptyScToReplace), "strokesToReplace"); + // + // we add a tag here so we can check for this in EraserBehavior.OnPointEraseResultChanged + // to determine if this method is the origin of an ArgumentException we harden against + // + ae.Data.Add("System.Windows.Ink.StrokeCollection", ""); + throw ae; + } + + int[] indexes = this.GetStrokeIndexes(strokesToReplace); + if (indexes == null) + { + // At least one stroke doesn't exist in our collection. We throw. + ArgumentException ae = new ArgumentException(SR.Get(SRID.InvalidRemovedStroke), "strokesToReplace"); + // + // we add a tag here so we can check for this in EraserBehavior.OnPointEraseResultChanged + // to determine if this method is the origin of an ArgumentException we harden against + // + ae.Data.Add("System.Windows.Ink.StrokeCollection", ""); + throw ae; + } + + + //validate that none of the relplaceWith strokes exist in the collection + for (int x = 0; x < strokesToReplaceWith.Count; x++) + { + Stroke stroke = strokesToReplaceWith[x]; + if (this.IndexOf(stroke) != -1) + { + throw new ArgumentException(SR.Get(SRID.StrokeIsDuplicated), "strokesToReplaceWith"); + } + } + + //bypass this.RemoveAt / InsertRange, which calls changed events + //and call our protected List directly + for (int x = indexes.Length - 1; x >= 0; x--) + { + //bypass this.RemoveAt, which calls changed events + //and call our protected List directly + //remove from the back so the indexes are correct + ((List) this.Items).RemoveAt(indexes[x]); + } + + if (strokesToReplaceWith.Count > 0) + { + //insert at the + ((List) this.Items).InsertRange(indexes[0], strokesToReplaceWith); + } + + + RaiseStrokesChanged(strokesToReplaceWith, strokesToReplace, indexes[0]); + } + + /// + /// called by StrokeCollectionSerializer during Load, bypasses Change notification + /// + internal void AddWithoutEvent(Stroke stroke) + { + Debug.Assert(stroke != null && IndexOf(stroke) == -1); + ((List) this.Items).Add(stroke); + } + + + /// Collection of extended properties on this StrokeCollection + internal ExtendedPropertyCollection ExtendedProperties + { + get + { + // + // internal getter is used by the serialization code + // + if (_extendedProperties == null) + { + _extendedProperties = new ExtendedPropertyCollection(); + } + + return _extendedProperties; + } + private set + { + // + // private setter used by copy + // + if (value != null) + { + _extendedProperties = value; + } + } + } + + /// + /// Event that notifies listeners whenever a change occurs in the set + /// of stroke objects contained in the collection. + /// + /// StrokeCollectionChangedEventHandler + public event StrokeCollectionChangedEventHandler StrokesChanged; + + /// + /// Event that notifies internal listeners whenever a change occurs in the set + /// of stroke objects contained in the collection. + /// + /// StrokeCollectionChangedEventHandler + internal event StrokeCollectionChangedEventHandler StrokesChangedInternal; + + /// + /// Event that notifies listeners whenever a change occurs in the propertyData + /// + /// PropertyDataChangedEventHandler + public event PropertyDataChangedEventHandler PropertyDataChanged; + + /// + /// INotifyPropertyChanged.PropertyChanged event, explicitly implemented + /// + event PropertyChangedEventHandler INotifyPropertyChanged.PropertyChanged + { + add { _propertyChanged += value; } + remove { _propertyChanged -= value; } + } + + /// + /// INotifyCollectionChanged.CollectionChanged event, explicitly implemented + /// + event NotifyCollectionChangedEventHandler INotifyCollectionChanged.CollectionChanged + { + add { _collectionChanged += value; } + remove { _collectionChanged -= value; } + } + + + /// Method called on derived classes whenever a drawing attributes + /// change has occurred in the stroke references in the collection + /// The change information for the stroke collection + /// StrokesChanged will not be called when drawing attributes or + /// custom attributes are changed. Changes that trigger StrokesChanged + /// include packets or points changing, modified tranforms, and stroke objects + /// being added or removed from the collection. + /// To ensure that events fire for event listeners, derived classes + /// should call this method. + protected virtual void OnStrokesChanged(StrokeCollectionChangedEventArgs e) + { + if (null == e) + { + throw new ArgumentNullException("e", SR.Get(SRID.EventArgIsNull)); + } + + //raise our internal event first. This is used by + //our Renderer and IncrementalHitTester since if they can assume + //they are the first in the delegate chain, they can be optimized + //to not have to handle out of order events caused by 3rd party code + //getting called first + if (this.StrokesChangedInternal != null) + { + this.StrokesChangedInternal(this, e); + } + if (this.StrokesChanged != null) + { + this.StrokesChanged(this, e); + } + if (_collectionChanged != null) + { + //raise CollectionChanged. We support the following + //NotifyCollectionChangedActions + NotifyCollectionChangedEventArgs args = null; + if (this.Count == 0) + { + //Reset + Debug.Assert(e.Removed.Count > 0); + args = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset); + } + else if (e.Added.Count == 0) + { + //Remove + args = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, e.Removed, e.Index); + } + else if (e.Removed.Count == 0) + { + //Add + args = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, e.Added, e.Index); + } + else + { + //Replace + args = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, e.Added, e.Removed, e.Index); + } + _collectionChanged(this, args); + } + } + + + /// + /// Method called on derived classes whenever a change occurs in + /// the PropertyData. + /// + /// Derived classes should call this method (their base class) + /// to ensure that event listeners are notified + protected virtual void OnPropertyDataChanged(PropertyDataChangedEventArgs e) + { + if (null == e) + { + throw new ArgumentNullException("e", SR.Get(SRID.EventArgIsNull)); + } + + if (this.PropertyDataChanged != null) + { + this.PropertyDataChanged(this, e); + } + } + + /// + /// Method called when a property change occurs to the StrokeCollection + /// + /// The EventArgs specifying the name of the changed property. + /// To follow the guidelines, this method should take a PropertyChangedEventArgs + /// instance, but every other INotifyPropertyChanged implementation follows this pattern. + protected virtual void OnPropertyChanged(PropertyChangedEventArgs e) + { + if (_propertyChanged != null) + { + _propertyChanged(this, e); + } + } + + + /// + /// Private helper that starts searching for stroke at index, + /// but will loop around before reporting -1. This is used for + /// Stroke.Remove(StrokeCollection). For example, if we're removing + /// strokes, chances are they are in contiguous order. If so, calling + /// IndexOf to validate each stroke is O(n2). If the strokes are in order + /// this produces closer to O(n), if they are not in order, it is no worse + /// + private int OptimisticIndexOf(int startingIndex, Stroke stroke) + { + Debug.Assert(startingIndex >= 0); + for (int x = startingIndex; x < this.Count; x++) + { + if (this[x] == stroke) + { + return x; + } + } + + //we didn't find anything on the first pass, now search the beginning + for (int x = 0; x < startingIndex; x++) + { + if (this[x] == stroke) + { + return x; + } + } + return -1; + } + + /// + /// Private helper that returns an array of indexes where the specified + /// strokes exist in this stroke collection. Returns null if at least one is not found. + /// + /// The indexes are sorted from smallest to largest + /// + /// + private int[] GetStrokeIndexes(StrokeCollection strokes) + { + //to keep from walking the StrokeCollection twice for each stroke, we will maintain an index of + //strokes to remove as we go + int[] indexes = new int[strokes.Count]; + for (int x = 0; x < indexes.Length; x++) + { + indexes[x] = Int32.MaxValue; + } + + int currentIndex = 0; + int highestIndex = -1; + int usedIndexCount = 0; + for (int x = 0; x < strokes.Count; x++) + { + currentIndex = this.OptimisticIndexOf(currentIndex, strokes[x]); + if (currentIndex == -1) + { + //stroke doe3sn't exist, bail out. + return null; + } + + // + // optimize for the most common case... replace is passes strokes + // in contiguous order. Only do the sort if we need to + // + if (currentIndex > highestIndex) + { + //write current to the next available slot + indexes[usedIndexCount++] = currentIndex; + highestIndex = currentIndex; + continue; + } + + //keep in sorted order (smallest to largest) with a simple insertion sort + for (int y = 0; y < indexes.Length; y++) + { + if (currentIndex < indexes[y]) + { + if (indexes[y] != Int32.MaxValue) + { + //shift from the end + for (int i = indexes.Length - 1; i > y; i--) + { + indexes[i] = indexes[i - 1]; + } + } + indexes[y] = currentIndex; + usedIndexCount++; + + if (currentIndex > highestIndex) + { + highestIndex = currentIndex; + } + break; + } + } + } + + return indexes; + } + + // This function will invoke OnStrokesChanged method. + // addedStrokes - the collection which contains the added strokes during the previous op. + // removedStrokes - the collection which contains the removed strokes during the previous op. + private void RaiseStrokesChanged(StrokeCollection addedStrokes, StrokeCollection removedStrokes, int index) + { + StrokeCollectionChangedEventArgs eventArgs = + new StrokeCollectionChangedEventArgs(addedStrokes, removedStrokes, index); + + // Invoke OnPropertyChanged + OnPropertyChanged(CountName); + OnPropertyChanged(IndexerName); + + // Invoke OnStrokesChanged which will fire the StrokesChanged event AND the CollectionChanged event. + OnStrokesChanged(eventArgs); + } + + private void OnPropertyChanged(string propertyName) + { + OnPropertyChanged(new PropertyChangedEventArgs(propertyName)); + } + + // Custom 'user-defined' attributes assigned to this collection + // In v1, these were called Ink.ExtendedProperties + private ExtendedPropertyCollection _extendedProperties = null; + + // The private PropertyChanged event + private PropertyChangedEventHandler _propertyChanged; + + // private CollectionChanged event raiser + private NotifyCollectionChangedEventHandler _collectionChanged; + + /// + /// Constants for the PropertyChanged event + /// + private const string IndexerName = "Item[]"; + private const string CountName = "Count"; + + // + // Nested types... + // + + /// + /// ReadOnlyStrokeCollection - for StrokeCollection.StrokesChanged event args... + /// + internal class ReadOnlyStrokeCollection : StrokeCollection, ICollection, IList + { + internal ReadOnlyStrokeCollection(StrokeCollection strokeCollection) + { + if (strokeCollection != null) + { + ((List) this.Items).AddRange(strokeCollection); + } + } + + /// + /// Change is not allowed. We would override SetItem, InsertItem etc but + /// they need to be sealed on StrokeCollection to prevent dupes from being added + /// + /// + protected override void OnStrokesChanged(StrokeCollectionChangedEventArgs e) + { + throw new NotSupportedException(SR.Get(SRID.StrokeCollectionIsReadOnly)); + } + + /// + /// IsReadOnly + /// + bool IList.IsReadOnly + { + get { return true; } + } + + /// + /// IsReadOnly + /// + bool ICollection.IsReadOnly + { + get { return true; } + } + } + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/StrokeCollection2.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/StrokeCollection2.cs new file mode 100644 index 0000000..b96a9d3 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/StrokeCollection2.cs @@ -0,0 +1,380 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics; + +namespace WpfInk.PresentationCore.System.Windows.Ink +{ + /// + /// The hit-testing API of StrokeCollection. + /// + internal partial class StrokeCollection : Collection, INotifyPropertyChanged, INotifyCollectionChanged + { + #region Public APIs + + + // ISSUE-2004/12/13-XIAOTU: In M8.2, the following two tap-hit APIs return the top-hit stroke, + // giving preference to non-highlighter strokes. We have decided not to treat highlighter and + // non-highlighter differently and only return the top-hit stroke. But there are two remaining + // open-issues on this: + // 1. Do we need to make these two APIs virtual, so user can treat highlighter differently if they + // want to? + // 2. Since we are only returning the top-hit stroke, should we use Stroke as the return type? + // +#if false + /// + /// Tap-hit. Hit tests all strokes within a point, and returns a StrokeCollection for these strokes.Internally does Stroke.HitTest(Point, 1pxlRectShape). + /// + /// A StrokeCollection that either empty or contains the top hit stroke + public StrokeCollection HitTest(Point point) + { + return PointHitTest(point, new RectangleStylusShape(1f, 1f)); + } + + /// + /// Tap-hit + /// + /// The central point + /// The diameter value of the circle + /// A StrokeCollection that either empty or contains the top hit stroke + public StrokeCollection HitTest(Point point, double diameter) + { + if (Double.IsNaN(diameter) || diameter < DrawingAttributes.MinWidth || diameter > DrawingAttributes.MaxWidth) + { + throw new ArgumentOutOfRangeException("diameter", SR.Get(SRID.InvalidDiameter)); + } + return PointHitTest(point, new EllipseStylusShape(diameter, diameter)); + } + + /// + /// Hit-testing with lasso + /// + /// points making the lasso + /// the margin value to tell whether a stroke + /// is in or outside of the rect + /// collection of strokes found inside the rectangle + public StrokeCollection HitTest(IEnumerable lassoPoints, int percentageWithinLasso) + { + // Check the input parameters + if (lassoPoints == null) + { + throw new System.ArgumentNullException("lassoPoints"); + } + if ((percentageWithinLasso < 0) || (percentageWithinLasso > 100)) + { + throw new System.ArgumentOutOfRangeException("percentageWithinLasso"); + } + + if (IEnumerablePointHelper.GetCount(lassoPoints) < 3) + { + return new StrokeCollection(); + } + + Lasso lasso = new SingleLoopLasso(); + lasso.AddPoints(lassoPoints); + + // Enumerate through the strokes and collect those captured by the lasso. + StrokeCollection lassoedStrokes = new StrokeCollection(); + foreach (Stroke stroke in this) + { + if (percentageWithinLasso == 0) + { + lassoedStrokes.Add(stroke); + } + else + { + StrokeInfo strokeInfo = null; + try + { + strokeInfo = new StrokeInfo(stroke); + + StylusPointCollection stylusPoints = strokeInfo.StylusPoints; + double target = strokeInfo.TotalWeight * percentageWithinLasso / 100.0f - Stroke.PercentageTolerance; + + for (int i = 0; i < stylusPoints.Count; i++) + { + if (true == lasso.Contains((Point)stylusPoints[i])) + { + target -= strokeInfo.GetPointWeight(i); + if (DoubleUtil.LessThanOrClose(target, 0f)) + { + lassoedStrokes.Add(stroke); + break; + } + } + } + } + finally + { + if (strokeInfo != null) + { + //detach from event handlers, or else we leak. + strokeInfo.Detach(); + } + } + } + } + + // Return the resulting collection + return lassoedStrokes; + } + + /// + /// Hit-testing with rectangle + /// + /// hitting rectangle + /// the percentage of the stroke that must be within + /// the bounds to be considered hit + /// collection of strokes found inside the rectangle + public StrokeCollection HitTest(Rect bounds, int percentageWithinBounds) + { + // Check the input parameters + if ((percentageWithinBounds < 0) || (percentageWithinBounds > 100)) + { + throw new System.ArgumentOutOfRangeException("percentageWithinBounds"); + } + if (bounds.IsEmpty) + { + return new StrokeCollection(); + } + + // Enumerate thru the strokes collect those found within the rectangle. + StrokeCollection hits = new StrokeCollection(); + foreach (Stroke stroke in this) + { + // samgeo - Presharp issue + // Presharp gives a warning when get methods might deref a null. It's complaining + // here that 'stroke'' could be null, but StrokeCollection never allows nulls to be added + // so this is not possible +#pragma warning disable 1634, 1691 +#pragma warning suppress 6506 + if (true == stroke.HitTest(bounds, percentageWithinBounds)) + { + hits.Add(stroke); + } +#pragma warning restore 1634, 1691 + } + return hits; + } + + + /// + /// Issue: what's the return value + /// + /// + /// + /// + public StrokeCollection HitTest(IEnumerable path, StylusShape stylusShape) + { + // Check the input parameters + if (stylusShape == null) + { + throw new System.ArgumentNullException("stylusShape"); + } + if (path == null) + { + throw new System.ArgumentNullException("path"); + } + if (IEnumerablePointHelper.GetCount(path) == 0) + { + return new StrokeCollection(); + } + + // validate input + ErasingStroke erasingStroke = new ErasingStroke(stylusShape, path); + Rect erasingBounds = erasingStroke.Bounds; + if (erasingBounds.IsEmpty) + { + return new StrokeCollection(); + } + StrokeCollection hits = new StrokeCollection(); + foreach (Stroke stroke in this) + { + // samgeo - Presharp issue + // Presharp gives a warning when get methods might deref a null. It's complaining + // here that 'stroke'' could be null, but StrokeCollection never allows nulls to be added + // so this is not possible +#pragma warning disable 1634, 1691 +#pragma warning suppress 6506 + if (erasingBounds.IntersectsWith(stroke.GetBounds()) && + erasingStroke.HitTest(StrokeNodeIterator.GetIterator(stroke, stroke.DrawingAttributes))) + { + hits.Add(stroke); + } +#pragma warning restore 1634, 1691 + } + + return hits; + } + + /// + /// Clips out all ink outside a given lasso + /// + /// lasso + public void Clip(IEnumerable lassoPoints) + { + // Check the input parameters + if (lassoPoints == null) + { + throw new System.ArgumentNullException("lassoPoints"); + } + + int length = IEnumerablePointHelper.GetCount(lassoPoints); + if (length == 0) + { + throw new ArgumentException(SR.Get(SRID.EmptyArray)); + } + + if (length < 3) + { + // + // if you're clipping with a point or a line with + // two points, it doesn't matter where the line is or if it + // intersects any of the strokes, the point or line has no region + // so technically everything in the strokecollection + // should be removed + // + this.Clear(); //raises the appropriate events + return; + } + + Lasso lasso = new SingleLoopLasso(); + lasso.AddPoints(lassoPoints); + + for (int i = 0; i < this.Count; i++) + { + Stroke stroke = this[i]; + StrokeCollection clipResult = stroke.Clip(stroke.HitTest(lasso)); + UpdateStrokeCollection(stroke, clipResult, ref i); + } + } + + /// + /// Clips out all ink outside a given rectangle. + /// + /// rectangle to clip with + public void Clip(Rect bounds) + { + if (bounds.IsEmpty == false) + { + Clip(new Point[4] { bounds.TopLeft, bounds.TopRight, bounds.BottomRight, bounds.BottomLeft }); + } + } + +#endif + + +#if false + + /// + /// Render the StrokeCollection under the specified DrawingContext. + /// + /// + public void Draw(DrawingContext context) + { + if (null == context) + { + throw new System.ArgumentNullException("context"); + } + + //The verification of UI context affinity is done in Stroke.Draw() + + List solidStrokes = new List(); + Dictionary> highLighters = new Dictionary>(); + + for (int i = 0; i < this.Count; i++) + { + Stroke stroke = this[i]; + List strokes; + if (stroke.DrawingAttributes.IsHighlighter) + { + // It's very important to override the Alpha value so that Colors of the same RGB vale + // but different Alpha would be in the same list. + Color color = StrokeRenderer.GetHighlighterColor(stroke.DrawingAttributes.Color); + if (highLighters.TryGetValue(color, out strokes) == false) + { + strokes = new List(); + highLighters.Add(color, strokes); + } + strokes.Add(stroke); + } + else + { + solidStrokes.Add(stroke); + } + } + + foreach (List strokes in highLighters.Values) + { + context.PushOpacity(StrokeRenderer.HighlighterOpacity); + try + { + foreach (Stroke stroke in strokes) + { + stroke.DrawInternal(context, StrokeRenderer.GetHighlighterAttributes(stroke, stroke.DrawingAttributes), + false /*Don't draw selected stroke as hollow*/); + } + } + finally + { + context.Pop(); + } + } + + foreach(Stroke stroke in solidStrokes) + { + stroke.DrawInternal(context, stroke.DrawingAttributes, false/*Don't draw selected stroke as hollow*/); + } + } +#endif + + #endregion + + + +#if false + + /// + /// Return all hit strokes that the StylusShape intersects and returns them in a StrokeCollection + /// + private StrokeCollection PointHitTest(Point point, StylusShape shape) + { + // Create the collection to return + StrokeCollection hits = new StrokeCollection(); + for (int i = 0; i < this.Count; i++) + { + Stroke stroke = this[i]; + if (stroke.HitTest(new Point[] { point }, shape)) + { + hits.Add(stroke); + } + } + + return hits; + } +#endif + + private void UpdateStrokeCollection(Stroke original, StrokeCollection toReplace, ref int index) + { + Debug.Assert(original != null && toReplace != null); + Debug.Assert(index >= 0 && index < this.Count); + if (toReplace.Count == 0) + { + Remove(original); + index--; + } + else if (!(toReplace.Count == 1 && toReplace[0] == original)) + { + Replace(original, toReplace); + + // Update the current index + index += toReplace.Count - 1; + } + } + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/StylusTip.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/StylusTip.cs new file mode 100644 index 0000000..a715ac3 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Ink/StylusTip.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +namespace WpfInk.PresentationCore.System.Windows.Ink +{ + /// + /// StylusTip + /// + internal enum StylusTip + { + /// + /// Rectangle + /// + Rectangle = 0, + + /// + /// Ellipse + /// + Ellipse + } + + /// + /// Internal helper to avoid costly call to Enum.IsDefined + /// + internal static class StylusTipHelper + { + internal static bool IsDefined(StylusTip stylusTip) + { + if (stylusTip < StylusTip.Rectangle || stylusTip > StylusTip.Ellipse) + { + return false; + } + return true; + } + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPoint.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPoint.cs new file mode 100644 index 0000000..02ae751 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPoint.cs @@ -0,0 +1,402 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.ObjectModel; +using System.Diagnostics; +using WpfInk.PresentationCore.System.Windows.Ink; + +namespace WpfInk.PresentationCore.System.Windows.Input.Stylus +{ + /// + /// Represents a single sampling point from a stylus input device + /// + internal struct StylusPoint : IEquatable + { + internal const float DefaultPressure = 0.5f; + + + private double _x; + private double _y; + private float _pressureFactor; + + #region Constructors + /// + /// StylusPoint + /// + /// x + /// y + public StylusPoint(double x, double y) + : this(x, y, DefaultPressure, null, null, false, false) + { + } + + /// + /// StylusPoint + /// + /// x + /// y + /// pressureFactor + public StylusPoint(double x, double y, float pressureFactor) + : this(x, y, pressureFactor, null, null, false, true) + { + } + + + /// + /// StylusPoint + /// + /// x + /// y + /// pressureFactor + /// stylusPointDescription + /// additionalValues + public StylusPoint(double x, double y, float pressureFactor, StylusPointDescription stylusPointDescription, int[] additionalValues) + : this(x, y, pressureFactor, stylusPointDescription, additionalValues, true, true) + { + } + + /// + /// internal ctor + /// + internal StylusPoint( + double x, + double y, + float pressureFactor, + StylusPointDescription stylusPointDescription, + int[] additionalValues, + bool validateAdditionalData, + bool validatePressureFactor) + { + if (Double.IsNaN(x)) + { + throw new ArgumentOutOfRangeException(nameof(x), SR.InvalidStylusPointXYNaN); + } + if (Double.IsNaN(y)) + { + throw new ArgumentOutOfRangeException(nameof(y), SR.InvalidStylusPointXYNaN); + } + + + //we don't validate pressure when called by StylusPointDescription.Reformat + if (validatePressureFactor && + (pressureFactor == Single.NaN || pressureFactor < 0.0f || pressureFactor > 1.0f)) + { + throw new ArgumentOutOfRangeException(nameof(pressureFactor), SR.InvalidPressureValue); + } + // + // only accept values between MaxXY and MinXY + // we don't throw when passed a value outside of that range, we just silently trunctate + // + _x = GetClampedXYValue(x); + _y = GetClampedXYValue(y); + _pressureFactor = pressureFactor; + + if (validateAdditionalData) + { + // + // called from the public verbose ctor + // + ArgumentNullException.ThrowIfNull(stylusPointDescription); + + // + // additionalValues can be null if PropertyCount == 3 (X, Y, P) + // + if (stylusPointDescription.PropertyCount > StylusPointDescription.RequiredCountOfProperties) + { + ArgumentNullException.ThrowIfNull(additionalValues); + } + + } + } + + + + #endregion Constructors + + /// + /// The Maximum X or Y value supported for backwards compatibility with previous inking platforms + /// + public static readonly double MaxXY = 81164736.28346430d; + + /// + /// The Minimum X or Y value supported for backwards compatibility with previous inking platforms + /// + public static readonly double MinXY = -81164736.32125960d; + + /// + /// X + /// + public double X + { + get { return _x; } + set + { + if (Double.IsNaN(value)) + { + throw new ArgumentOutOfRangeException("X", SR.InvalidStylusPointXYNaN); + } + // + // only accept values between MaxXY and MinXY + // we don't throw when passed a value outside of that range, we just silently trunctate + // + _x = GetClampedXYValue(value); + } + } + + /// + /// Y + /// + public double Y + { + get { return _y; } + set + { + if (Double.IsNaN(value)) + { + throw new ArgumentOutOfRangeException("Y", SR.InvalidStylusPointXYNaN); + } + // + // only accept values between MaxXY and MinXY + // we don't throw when passed a value outside of that range, we just silently trunctate + // + _y = GetClampedXYValue(value); + } + } + + /// + /// PressureFactor. A value between 0.0 (no pressure) and 1.0 (max pressure) + /// + public float PressureFactor + { + get + { + // + // note that pressure can be stored a > 1 or < 0. + // we need to clamp if this is the case + // + if (_pressureFactor > 1.0f) + { + return 1.0f; + } + if (_pressureFactor < 0.0f) + { + return 0.0f; + } + return _pressureFactor; + } + set + { + if (value < 0.0f || value > 1.0f) + { + throw new ArgumentOutOfRangeException("PressureFactor", SR.InvalidPressureValue); + } + _pressureFactor = value; + } + } + + + /// + /// Provides read access to all stylus properties + /// + /// The StylusPointPropertyIds of the property to retrieve + public int GetPropertyValue(StylusPointProperty stylusPointProperty) + { + ArgumentNullException.ThrowIfNull(stylusPointProperty); + if (stylusPointProperty.Id == StylusPointPropertyIds.X) + { + return (int) _x; + } + else if (stylusPointProperty.Id == StylusPointPropertyIds.Y) + { + return (int) _y; + } + else if (stylusPointProperty.Id == StylusPointPropertyIds.NormalPressure) + { + //StylusPointPropertyInfo info = + // this.Description.GetPropertyInfo(StylusPointProperties.NormalPressure); + + //int max = info.Maximum; + return (int) _pressureFactor * 1024; + } + else + { + throw new ArgumentException(SR.InvalidStylusPointProperty, nameof(stylusPointProperty)); + } + } + + /// + /// Explicit cast converter between StylusPoint and Point + /// + /// stylusPoint + public static explicit operator Point(StylusPoint stylusPoint) + { + return new Point(stylusPoint.X, stylusPoint.Y); + } + + /// + /// Allows languages that don't support operator overloading + /// to convert to a point + /// + public Point ToPoint() + { + return new Point(this.X, this.Y); + } + + + /// + /// Compares two StylusPoint instances for exact equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which are logically equal may fail. + /// Furthermore, using this equality operator, Double.NaN is not equal to itself. + /// Descriptions must match for equality to succeed and additional values must match + /// + /// + /// bool - true if the two Stylus instances are exactly equal, false otherwise + /// + /// The first StylusPoint to compare + /// The second StylusPoint to compare + public static bool operator ==(StylusPoint stylusPoint1, StylusPoint stylusPoint2) + { + return StylusPoint.Equals(stylusPoint1, stylusPoint2); + } + + /// + /// Compares two StylusPoint instances for exact inequality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which are logically equal may fail. + /// Furthermore, using this equality operator, Double.NaN is not equal to itself. + /// + /// + /// bool - true if the two Stylus instances are exactly inequal, false otherwise + /// + /// The first StylusPoint to compare + /// The second StylusPoint to compare + public static bool operator !=(StylusPoint stylusPoint1, StylusPoint stylusPoint2) + { + return !StylusPoint.Equals(stylusPoint1, stylusPoint2); + } + + /// + /// Compares two StylusPoint instances for exact equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which are logically equal may fail. + /// Furthermore, using this equality operator, Double.NaN is not equal to itself. + /// Descriptions must match for equality to succeed and additional values must match + /// + /// + /// bool - true if the two Stylus instances are exactly equal, false otherwise + /// + /// The first StylusPoint to compare + /// The second StylusPoint to compare + public static bool Equals(StylusPoint stylusPoint1, StylusPoint stylusPoint2) + { + // + // do the cheap comparison first + // + bool membersEqual = + stylusPoint1._x == stylusPoint2._x && + stylusPoint1._y == stylusPoint2._y && + stylusPoint1._pressureFactor == stylusPoint2._pressureFactor; + + if (!membersEqual) + { + return false; + } + + return false; + } + + /// + /// Compares two StylusPoint instances for exact equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which are logically equal may fail. + /// Furthermore, using this equality operator, Double.NaN is not equal to itself. + /// Descriptions must match for equality to succeed and additional values must match + /// + /// + /// bool - true if the object is an instance of StylusPoint and if it's equal to "this". + /// + /// The object to compare to "this" + public override bool Equals(object o) + { + if ((null == o) || !(o is StylusPoint)) + { + return false; + } + + StylusPoint value = (StylusPoint) o; + return StylusPoint.Equals(this, value); + } + + /// + /// Equals - compares this StylusPoint with the passed in object. In this equality + /// Double.NaN is equal to itself, unlike in numeric equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which + /// are logically equal may fail. + /// + /// + /// bool - true if "value" is equal to "this". + /// + /// The StylusPoint to compare to "this" + public bool Equals(StylusPoint value) + { + return StylusPoint.Equals(this, value); + } + /// + /// Returns the HashCode for this StylusPoint + /// + /// + /// int - the HashCode for this StylusPoint + /// + public override int GetHashCode() + { + int hash = + _x.GetHashCode() ^ + _y.GetHashCode() ^ + _pressureFactor.GetHashCode(); + + return hash; + } + + + /// + /// Internal helper used by SPC.Reformat to preserve the pressureFactor + /// + internal float GetUntruncatedPressureFactor() + { + return _pressureFactor; + } + + /// + /// Internal helper to determine if a stroke has default pressure + /// This is used by ISF serialization to not serialize pressure + /// + internal bool HasDefaultPressure + { + get + { + return (_pressureFactor == DefaultPressure); + } + } + + /// + /// Private helper that returns a double clamped to MaxXY or MinXY + /// We only accept values in this range to support ISF serialization + /// + private static double GetClampedXYValue(double xyValue) + { + if (xyValue > MaxXY) + { + return MaxXY; + } + if (xyValue < MinXY) + { + return MinXY; + } + + return xyValue; + } + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointCollection.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointCollection.cs new file mode 100644 index 0000000..71340c1 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointCollection.cs @@ -0,0 +1,478 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics; +using System.Windows.Input; + +namespace WpfInk.PresentationCore.System.Windows.Input.Stylus +{ + /// + /// StylusPointCollection + /// + internal class StylusPointCollection : Collection + { + /// + /// Changed event, anytime the data in this collection changes, this event is raised + /// + public event EventHandler? Changed; + + /// + /// Internal only changed event used by Stroke to prevent zero count strokes + /// + internal event CancelEventHandler? CountGoingToZero; + + /// + /// StylusPointCollection + /// + public StylusPointCollection() + { + } + + /// + /// StylusPointCollection + /// + /// initialCapacity + public StylusPointCollection(int initialCapacity) + : this() + { + if (initialCapacity < 0) + { + throw new ArgumentException(SR.InvalidStylusPointConstructionZeroLengthCollection, nameof(initialCapacity)); + } + ((List) this.Items).Capacity = initialCapacity; + } + + + /// + /// StylusPointCollection + /// + /// stylusPointDescription + /// initialCapacity + public StylusPointCollection(StylusPointDescription stylusPointDescription, int initialCapacity) + : this() + { + if (initialCapacity < 0) + { + throw new ArgumentException(SR.InvalidStylusPointConstructionZeroLengthCollection, nameof(initialCapacity)); + } + ((List) this.Items).Capacity = initialCapacity; + } + + + /// + /// StylusPointCollection + /// + /// points + public StylusPointCollection(IEnumerable points) + : this() + { + ArgumentNullException.ThrowIfNull(points); + + List stylusPoints = new List(); + foreach (Point point in points) + { + //this can throw (since point.X or Y can be beyond our range) + //don't add to our internal collection until after we instance + //all of the styluspoints and we know the ranges are valid + stylusPoints.Add(new StylusPoint(point.X, point.Y)); + } + + if (stylusPoints.Count == 0) + { + throw new ArgumentException(SR.InvalidStylusPointConstructionZeroLengthCollection, nameof(points)); + } + + ((List) this.Items).Capacity = stylusPoints.Count; + ((List) this.Items).AddRange(stylusPoints); + } + +#if false + + /// + /// Internal ctor called by input with a raw int[] + /// + /// stylusPointDescription + /// rawPacketData + /// tabletToView + /// tabletToView + internal StylusPointCollection(StylusPointDescription stylusPointDescription, int[] rawPacketData, GeneralTransform tabletToView, Matrix tabletToViewMatrix) + { + ArgumentNullException.ThrowIfNull(stylusPointDescription); + _stylusPointDescription = stylusPointDescription; + + int lengthPerPoint = stylusPointDescription.GetInputArrayLengthPerPoint(); + int logicalPointCount = rawPacketData.Length / lengthPerPoint; + Debug.Assert(0 == rawPacketData.Length % lengthPerPoint, "Invalid assumption about packet length, there shouldn't be any remainder"); + + // + // set our capacity and validate + // + ((List) this.Items).Capacity = logicalPointCount; + for (int count = 0, i = 0; count < logicalPointCount; count++, i += lengthPerPoint) + { + //first, determine the x, y values by xf-ing them + Point p = new Point(rawPacketData[i], rawPacketData[i + 1]); + if (tabletToView != null) + { + tabletToView.TryTransform(p, out p); + } + else + { + p = tabletToViewMatrix.Transform(p); + } + + int startIndex = 2; + bool containsTruePressure = stylusPointDescription.ContainsTruePressure; + if (containsTruePressure) + { + //don't copy pressure in the int[] for extra data + startIndex++; + } + + int[] data = null; + int dataLength = lengthPerPoint - startIndex; + if (dataLength > 0) + { + //copy the rest of the data + var rawArrayStartIndex = i + startIndex; + data = rawPacketData.AsSpan(rawArrayStartIndex, dataLength).ToArray(); + } + + StylusPoint newPoint = new StylusPoint(p.X, p.Y, StylusPoint.DefaultPressure, _stylusPointDescription, data, false, false); + if (containsTruePressure) + { + //use the algorithm to set pressure in StylusPoint + int pressure = rawPacketData[i + 2]; + newPoint.SetPropertyValue(StylusPointProperties.NormalPressure, pressure); + } + + //this does not go through our protected virtuals + ((List) this.Items).Add(newPoint); + } + } +#endif + + /// + /// Adds the StylusPoints in the StylusPointCollection to this StylusPointCollection + /// + /// stylusPoints + public void Add(StylusPointCollection stylusPoints) + { + //note that we don't raise an exception if stylusPoints.Count == 0 + ArgumentNullException.ThrowIfNull(stylusPoints); + + + // cache count outside of the loop, so if this SPC is ever passed + // we don't loop forever + int count = stylusPoints.Count; + for (int x = 0; x < count; x++) + { + StylusPoint stylusPoint = stylusPoints[x]; + //this does not go through our protected virtuals + ((List) this.Items).Add(stylusPoint); + } + + if (stylusPoints.Count > 0) + { + OnChanged(EventArgs.Empty); + } + } + + /// + /// called by base class Collection<T> when the list is being cleared; + /// raises a CollectionChanged event to any listeners + /// + protected sealed override void ClearItems() + { + if (CanGoToZero()) + { + base.ClearItems(); + OnChanged(EventArgs.Empty); + } + else + { + throw new InvalidOperationException(SR.InvalidStylusPointCollectionZeroCount); + } + } + + /// + /// called by base class Collection<T> when an item is removed from list; + /// raises a CollectionChanged event to any listeners + /// + protected sealed override void RemoveItem(int index) + { + if (this.Count > 1 || CanGoToZero()) + { + base.RemoveItem(index); + OnChanged(EventArgs.Empty); + } + else + { + throw new InvalidOperationException(SR.InvalidStylusPointCollectionZeroCount); + } + } + + /// + /// called by base class Collection<T> when an item is added to list; + /// raises a CollectionChanged event to any listeners + /// + protected sealed override void InsertItem(int index, StylusPoint stylusPoint) + { + base.InsertItem(index, stylusPoint); + + OnChanged(EventArgs.Empty); + } + + /// + /// called by base class Collection<T> when an item is set in list; + /// raises a CollectionChanged event to any listeners + /// + protected sealed override void SetItem(int index, StylusPoint stylusPoint) + { + base.SetItem(index, stylusPoint); + + OnChanged(EventArgs.Empty); + } + + /// + /// Clone + /// + public StylusPointCollection Clone() + { + return this.Clone(/*System.Windows.Media.Transform.Identity,*/ /*this.Description,*/ this.Count); + } + + /// + /// Explicit cast converter between StylusPointCollection and Point[] + /// + /// stylusPoints + public static explicit operator Point[](StylusPointCollection stylusPoints) + { + if (stylusPoints == null) + { + return null; + } + + Point[] points = new Point[stylusPoints.Count]; + for (int i = 0; i < stylusPoints.Count; i++) + { + points[i] = new Point(stylusPoints[i].X, stylusPoints[i].Y); + } + return points; + } + +#if false + /// + /// Clone and truncate + /// + /// The maximum count of points to clone (used by GestureRecognizer) + /// + internal StylusPointCollection Clone(int count) + { + ArgumentOutOfRangeException.ThrowIfGreaterThan(count, this.Count); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count); + + return this.Clone(System.Windows.Media.Transform.Identity, this.Description, count); + } + + /// + /// Clone with a transform, used by input + /// + internal StylusPointCollection Clone(GeneralTransform transform, StylusPointDescription descriptionToUse) + { + return this.Clone(transform, descriptionToUse, this.Count); + } +#endif + + + /// + /// Private clone implementation + /// + private StylusPointCollection Clone(/*GeneralTransform transform,*/ /*StylusPointDescription descriptionToUse,*/ int count) + { + Debug.Assert(count <= this.Count); + // + // We don't need to copy our _stylusPointDescription because it is immutable + // and we don't need to copy our StylusPoints, because they are structs. + // + StylusPointCollection newCollection = + new StylusPointCollection(count); + + bool isIdentity = //(transform is Transform) ? ((Transform) transform).IsIdentity : false; + true; + for (int x = 0; x < count; x++) + { + if (isIdentity) + { + ((List) newCollection.Items).Add(this[x]); + } + else + { +#if false + Point point = new Point(); + StylusPoint stylusPoint = this[x]; + point.X = stylusPoint.X; + point.Y = stylusPoint.Y; + transform.TryTransform(point, out point); + stylusPoint.X = point.X; + stylusPoint.Y = point.Y; + ((List) newCollection.Items).Add(stylusPoint); +#endif + throw new NotImplementedException(); + } + } + return newCollection; + } + + /// + /// Protected virtual for raising changed notification + /// + /// + protected virtual void OnChanged(EventArgs e) + { + ArgumentNullException.ThrowIfNull(e); + if (this.Changed != null) + { + this.Changed(this, e); + } + } + +#if false + /// + /// Transform the StylusPoints in this collection by the specified transform + /// + /// transform + internal void Transform(GeneralTransform transform) + { + Point point = new Point(); + for (int i = 0; i < this.Count; i++) + { + StylusPoint stylusPoint = this[i]; + point.X = stylusPoint.X; + point.Y = stylusPoint.Y; + transform.TryTransform(point, out point); + stylusPoint.X = point.X; + stylusPoint.Y = point.Y; + + //this does not go through our protected virtuals + ((List) this.Items)[i] = stylusPoint; + } + + if (this.Count > 0) + { + this.OnChanged(EventArgs.Empty); + } + } + /// + /// Reformat + /// + /// subsetToReformatTo + public StylusPointCollection Reformat(StylusPointDescription subsetToReformatTo) + { + return Reformat(subsetToReformatTo, System.Windows.Media.Transform.Identity); + } + + /// + /// Helper that transforms and scales in one go + /// + internal StylusPointCollection Reformat(StylusPointDescription subsetToReformatTo, GeneralTransform transform) + { + if (!subsetToReformatTo.IsSubsetOf(this.Description)) + { + throw new ArgumentException(SR.InvalidStylusPointDescriptionSubset, nameof(subsetToReformatTo)); + } + + StylusPointDescription subsetToReformatToWithCurrentMetrics = + StylusPointDescription.GetCommonDescription(subsetToReformatTo, + this.Description); //preserve metrics from this spd + + if (StylusPointDescription.AreCompatible(this.Description, subsetToReformatToWithCurrentMetrics) && + (transform is Transform) && ((Transform) transform).IsIdentity) + { + //subsetToReformatTo might have different x, y, p metrics + return this.Clone(transform, subsetToReformatToWithCurrentMetrics); + } + + // + // we really need to reformat this... + // + StylusPointCollection newCollection = new StylusPointCollection(subsetToReformatToWithCurrentMetrics, this.Count); + int additionalDataCount = subsetToReformatToWithCurrentMetrics.GetExpectedAdditionalDataCount(); + + ReadOnlyCollection properties + = subsetToReformatToWithCurrentMetrics.GetStylusPointProperties(); + bool isIdentity = (transform is Transform) ? ((Transform) transform).IsIdentity : false; + + for (int i = 0; i < this.Count; i++) + { + StylusPoint stylusPoint = this[i]; + + double xCoord = stylusPoint.X; + double yCoord = stylusPoint.Y; + float pressure = stylusPoint.GetUntruncatedPressureFactor(); + + if (!isIdentity) + { + Point p = new Point(xCoord, yCoord); + transform.TryTransform(p, out p); + xCoord = p.X; + yCoord = p.Y; + } + + int[] newData = null; + if (additionalDataCount > 0) + { + //don't init, we'll do that below + newData = new int[additionalDataCount]; + } + + StylusPoint newStylusPoint = + new StylusPoint(xCoord, yCoord, pressure, subsetToReformatToWithCurrentMetrics, newData, false, false); + + //start at 3, skipping x, y, pressure + for (int x = StylusPointDescription.RequiredCountOfProperties/*3*/; x < properties.Count; x++) + { + int value = stylusPoint.GetPropertyValue(properties[x]); + newStylusPoint.SetPropertyValue(properties[x], value, copyBeforeWrite: false); + } + //bypass validation + ((List) newCollection.Items).Add(newStylusPoint); + } + return newCollection; + } +#endif + + + /// + /// Private helper use to consult with any listening strokes if it is safe to go to zero count + /// + /// + private bool CanGoToZero() + { + if (null == this.CountGoingToZero) + { + // + // no one is listening + // + return true; + } + + CancelEventArgs e = new CancelEventArgs + { + Cancel = false + }; + + // + // call the listeners + // + this.CountGoingToZero(this, e); + Debug.Assert(e.Cancel, "This event should always be cancelled"); + + return !e.Cancel; + } + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointDescription.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointDescription.cs new file mode 100644 index 0000000..20a7ffc --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointDescription.cs @@ -0,0 +1,420 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Windows.Input; +using WpfInk.PresentationCore.System.Windows.Ink; + +namespace WpfInk.PresentationCore.System.Windows.Input.Stylus +{ + /// + /// StylusPointDescription describes the properties that a StylusPoint supports. + /// + internal class StylusPointDescription + { + /// + /// Internal statics for our magic numbers + /// + internal const int RequiredCountOfProperties = 3; + internal const int RequiredXIndex = 0; + internal const int RequiredYIndex = 1; + internal const int RequiredPressureIndex = 2; + internal const int MaximumButtonCount = 31; + + private int _buttonCount = 0; + private int _originalPressureIndex = RequiredPressureIndex; + private StylusPointPropertyInfo[] _stylusPointPropertyInfos; + + /// + /// StylusPointDescription + /// + public StylusPointDescription() + { + //implement the default packet description + _stylusPointPropertyInfos = + new StylusPointPropertyInfo[] + { + StylusPointPropertyInfoDefaults.X, + StylusPointPropertyInfoDefaults.Y, + StylusPointPropertyInfoDefaults.NormalPressure + }; + } + + /// + /// StylusPointDescription + /// + public StylusPointDescription(IEnumerable stylusPointPropertyInfos) + { + ArgumentNullException.ThrowIfNull(stylusPointPropertyInfos); + List infos = + new List(stylusPointPropertyInfos); + + if (infos.Count < RequiredCountOfProperties || + infos[RequiredXIndex].Id != StylusPointPropertyIds.X || + infos[RequiredYIndex].Id != StylusPointPropertyIds.Y || + infos[RequiredPressureIndex].Id != StylusPointPropertyIds.NormalPressure) + { + throw new ArgumentException(SR.InvalidStylusPointDescription, nameof(stylusPointPropertyInfos)); + } + + // + // look for duplicates, validate that buttons are last + // + List seenIds = new List(); + seenIds.Add(StylusPointPropertyIds.X); + seenIds.Add(StylusPointPropertyIds.Y); + seenIds.Add(StylusPointPropertyIds.NormalPressure); + + int buttonCount = 0; + for (int x = RequiredCountOfProperties; x < infos.Count; x++) + { + if (seenIds.Contains(infos[x].Id)) + { + throw new ArgumentException(SR.InvalidStylusPointDescriptionDuplicatesFound, nameof(stylusPointPropertyInfos)); + } + if (infos[x].IsButton) + { + buttonCount++; + } + else + { + //this is not a button, make sure we haven't seen one before + if (buttonCount > 0) + { + throw new ArgumentException(SR.InvalidStylusPointDescriptionButtonsMustBeLast, nameof(stylusPointPropertyInfos)); + } + } + seenIds.Add(infos[x].Id); + } + if (buttonCount > MaximumButtonCount) + { + throw new ArgumentException(SR.InvalidStylusPointDescriptionTooManyButtons, nameof(stylusPointPropertyInfos)); + } + + _buttonCount = buttonCount; + _stylusPointPropertyInfos = infos.ToArray(); + } + + /// + /// StylusPointDescription + /// + /// stylusPointPropertyInfos + /// originalPressureIndex - does the digitizer really support pressure? If so, the index this was at + internal StylusPointDescription(IEnumerable stylusPointPropertyInfos, int originalPressureIndex) + : this(stylusPointPropertyInfos) + { + _originalPressureIndex = originalPressureIndex; + } + + /// + /// HasProperty + /// + /// stylusPointProperty + public bool HasProperty(StylusPointProperty stylusPointProperty) + { + ArgumentNullException.ThrowIfNull(stylusPointProperty); + + int index = IndexOf(stylusPointProperty.Id); + if (-1 == index) + { + return false; + } + return true; + } + + /// + /// The count of properties this StylusPointDescription contains + /// + public int PropertyCount + { + get { return _stylusPointPropertyInfos.Length; } + } + + /// + /// GetProperty + /// + /// stylusPointProperty + public StylusPointPropertyInfo GetPropertyInfo(StylusPointProperty stylusPointProperty) + { + ArgumentNullException.ThrowIfNull(stylusPointProperty); + return GetPropertyInfo(stylusPointProperty.Id); + } + + /// + /// GetPropertyInfo + /// + /// guid + internal StylusPointPropertyInfo GetPropertyInfo(Guid guid) + { + int index = IndexOf(guid); + if (-1 == index) + { + //we didn't find it + throw new ArgumentException("stylusPointProperty"); + } + return _stylusPointPropertyInfos[index]; + } + + /// + /// Returns the index of the given StylusPointProperty by ID, or -1 if none is found + /// + internal int GetPropertyIndex(Guid guid) + { + return IndexOf(guid); + } + + /// + /// GetStylusPointProperties + /// + public ReadOnlyCollection GetStylusPointProperties() + { + return new ReadOnlyCollection(_stylusPointPropertyInfos); + } + + /// + /// GetStylusPointPropertyIdss + /// + internal Guid[] GetStylusPointPropertyIds() + { + Guid[] ret = new Guid[_stylusPointPropertyInfos.Length]; + for (int x = 0; x < ret.Length; x++) + { + ret[x] = _stylusPointPropertyInfos[x].Id; + } + return ret; + } + + /// + /// Internal helper for determining how many ints in a raw int array + /// correspond to one point we get from the input system + /// + internal int GetInputArrayLengthPerPoint() + { + int buttonLength = _buttonCount > 0 ? 1 : 0; + int propertyLength = (_stylusPointPropertyInfos.Length - _buttonCount) + buttonLength; + if (!this.ContainsTruePressure) + { + propertyLength--; + } + return propertyLength; + } + + /// + /// Internal helper for determining how many members a StylusPoint's + /// internal int[] should be for additional data + /// + internal int GetExpectedAdditionalDataCount() + { + int buttonLength = _buttonCount > 0 ? 1 : 0; + int expectedLength = ((_stylusPointPropertyInfos.Length - _buttonCount) + buttonLength) - 3 /*x, y, p*/; + return expectedLength; + } + + /// + /// Internal helper for determining how many ints in a raw int array + /// correspond to one point when saving to himetric + /// + /// + internal int GetOutputArrayLengthPerPoint() + { + int length = GetInputArrayLengthPerPoint(); + if (!this.ContainsTruePressure) + { + length++; + } + return length; + } + + /// + /// Internal helper for determining how many buttons are present + /// + internal int ButtonCount + { + get + { + return _buttonCount; + } + } + + /// + /// Internal helper for determining what bit position the button is at + /// + internal int GetButtonBitPosition(StylusPointProperty buttonProperty) + { + if (!buttonProperty.IsButton) + { + throw new InvalidOperationException(); + } + int buttonIndex = 0; + for (int x = _stylusPointPropertyInfos.Length - _buttonCount; //start of the buttons + x < _stylusPointPropertyInfos.Length; x++) + { + if (_stylusPointPropertyInfos[x].Id == buttonProperty.Id) + { + return buttonIndex; + } + if (_stylusPointPropertyInfos[x].IsButton) + { + // we're in the buttons, but this isn't the right one, + // bump the button index and keep looking + buttonIndex++; + } + } + return -1; + } + + /// + /// ContainsTruePressure - true if this StylusPointDescription was instanced + /// by a TabletDevice or by ISF serialization that contains NormalPressure + /// + internal bool ContainsTruePressure + { + get { return (_originalPressureIndex != -1); } + } + + /// + /// Internal helper to determine the original pressure index + /// + internal int OriginalPressureIndex + { + get { return _originalPressureIndex; } + } + + /// + /// Returns true if the two StylusPointDescriptions have the same StylusPointProperties. Metrics are ignored. + /// + /// stylusPointDescription1 + /// stylusPointDescription2 + public static bool AreCompatible(StylusPointDescription stylusPointDescription1, StylusPointDescription stylusPointDescription2) + { + if (stylusPointDescription1 == null || stylusPointDescription2 == null) + { + throw new ArgumentNullException("stylusPointDescription"); + } + + // if a StylusPointDescription is not null, then _stylusPointPropertyInfos is not null. + // + // ignore X, Y, Pressure - they are guaranteed to be the first3 members + // + Debug.Assert(stylusPointDescription1._stylusPointPropertyInfos.Length >= RequiredCountOfProperties && + stylusPointDescription1._stylusPointPropertyInfos[0].Id == StylusPointPropertyIds.X && + stylusPointDescription1._stylusPointPropertyInfos[1].Id == StylusPointPropertyIds.Y && + stylusPointDescription1._stylusPointPropertyInfos[2].Id == StylusPointPropertyIds.NormalPressure); + + Debug.Assert(stylusPointDescription2._stylusPointPropertyInfos.Length >= RequiredCountOfProperties && + stylusPointDescription2._stylusPointPropertyInfos[0].Id == StylusPointPropertyIds.X && + stylusPointDescription2._stylusPointPropertyInfos[1].Id == StylusPointPropertyIds.Y && + stylusPointDescription2._stylusPointPropertyInfos[2].Id == StylusPointPropertyIds.NormalPressure); + + if (stylusPointDescription1._stylusPointPropertyInfos.Length != stylusPointDescription2._stylusPointPropertyInfos.Length) + { + return false; + } + for (int x = RequiredCountOfProperties; x < stylusPointDescription1._stylusPointPropertyInfos.Length; x++) + { + if (!StylusPointPropertyInfo.AreCompatible(stylusPointDescription1._stylusPointPropertyInfos[x], stylusPointDescription2._stylusPointPropertyInfos[x])) + { + return false; + } + } + + return true; + } + + /// + /// Returns a new StylusPointDescription with the common StylusPointProperties from both + /// + /// stylusPointDescription + /// stylusPointDescriptionPreserveInfo + /// The StylusPointProperties from stylusPointDescriptionPreserveInfo will be returned in the new StylusPointDescription + public static StylusPointDescription GetCommonDescription(StylusPointDescription stylusPointDescription, StylusPointDescription stylusPointDescriptionPreserveInfo) + { + ArgumentNullException.ThrowIfNull(stylusPointDescription); + ArgumentNullException.ThrowIfNull(stylusPointDescriptionPreserveInfo); + + + // if a StylusPointDescription is not null, then _stylusPointPropertyInfos is not null. + // + // ignore X, Y, Pressure - they are guaranteed to be the first3 members + // + Debug.Assert(stylusPointDescription._stylusPointPropertyInfos.Length >= 3 && + stylusPointDescription._stylusPointPropertyInfos[0].Id == StylusPointPropertyIds.X && + stylusPointDescription._stylusPointPropertyInfos[1].Id == StylusPointPropertyIds.Y && + stylusPointDescription._stylusPointPropertyInfos[2].Id == StylusPointPropertyIds.NormalPressure); + + Debug.Assert(stylusPointDescriptionPreserveInfo._stylusPointPropertyInfos.Length >= 3 && + stylusPointDescriptionPreserveInfo._stylusPointPropertyInfos[0].Id == StylusPointPropertyIds.X && + stylusPointDescriptionPreserveInfo._stylusPointPropertyInfos[1].Id == StylusPointPropertyIds.Y && + stylusPointDescriptionPreserveInfo._stylusPointPropertyInfos[2].Id == StylusPointPropertyIds.NormalPressure); + + + //add x, y, p + List commonProperties = new List(); + commonProperties.Add(stylusPointDescriptionPreserveInfo._stylusPointPropertyInfos[0]); + commonProperties.Add(stylusPointDescriptionPreserveInfo._stylusPointPropertyInfos[1]); + commonProperties.Add(stylusPointDescriptionPreserveInfo._stylusPointPropertyInfos[2]); + + //add common properties + for (int x = RequiredCountOfProperties; x < stylusPointDescription._stylusPointPropertyInfos.Length; x++) + { + for (int y = RequiredCountOfProperties; y < stylusPointDescriptionPreserveInfo._stylusPointPropertyInfos.Length; y++) + { + if (StylusPointPropertyInfo.AreCompatible(stylusPointDescription._stylusPointPropertyInfos[x], + stylusPointDescriptionPreserveInfo._stylusPointPropertyInfos[y])) + { + commonProperties.Add(stylusPointDescriptionPreserveInfo._stylusPointPropertyInfos[y]); + } + } + } + + return new StylusPointDescription(commonProperties); + } + + /// + /// Returns true if this StylusPointDescription is a subset + /// of the StylusPointDescription passed in + /// + /// stylusPointDescriptionSuperset + /// + public bool IsSubsetOf(StylusPointDescription stylusPointDescriptionSuperset) + { + ArgumentNullException.ThrowIfNull(stylusPointDescriptionSuperset); + if (stylusPointDescriptionSuperset._stylusPointPropertyInfos.Length < _stylusPointPropertyInfos.Length) + { + return false; + } + // + // iterate through our local properties and make sure that the + // superset contains them + // + for (int x = 0; x < _stylusPointPropertyInfos.Length; x++) + { + Guid id = _stylusPointPropertyInfos[x].Id; + if (-1 == stylusPointDescriptionSuperset.IndexOf(id)) + { + return false; + } + } + return true; + } + + /// + /// Returns the index of the given StylusPointProperty, or -1 if none is found + /// + /// propertyId + private int IndexOf(Guid propertyId) + { + for (int x = 0; x < _stylusPointPropertyInfos.Length; x++) + { + if (_stylusPointPropertyInfos[x].Id == propertyId) + { + return x; + } + } + return -1; + } + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointProperties.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointProperties.cs new file mode 100644 index 0000000..820763b --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointProperties.cs @@ -0,0 +1,146 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Windows.Input; +using WpfInk.PresentationCore.System.Windows.Ink; + +namespace WpfInk.PresentationCore.System.Windows.Input.Stylus +{ + /// + /// StylusPointProperties + /// + internal static class StylusPointProperties + { + /// + /// X + /// + public static readonly StylusPointProperty X = + new StylusPointProperty(StylusPointPropertyIds.X, false); + + /// + /// Y + /// + public static readonly StylusPointProperty Y = + new StylusPointProperty(StylusPointPropertyIds.Y, false); + + /// + /// Z + /// + public static readonly StylusPointProperty Z = + new StylusPointProperty(StylusPointPropertyIds.Z, false); + + /// + /// Width + /// + public static readonly StylusPointProperty Width = + new StylusPointProperty(StylusPointPropertyIds.Width, false); + + /// + /// Height + /// + public static readonly StylusPointProperty Height = + new StylusPointProperty(StylusPointPropertyIds.Height, false); + + /// + /// SystemContact + /// + public static readonly StylusPointProperty SystemTouch = + new StylusPointProperty(StylusPointPropertyIds.SystemTouch, false); + + /// + /// PacketStatus + /// + public static readonly StylusPointProperty PacketStatus = + new StylusPointProperty(StylusPointPropertyIds.PacketStatus, false); + + /// + /// SerialNumber + /// + public static readonly StylusPointProperty SerialNumber = + new StylusPointProperty(StylusPointPropertyIds.SerialNumber, false); + + /// + /// NormalPressure + /// + public static readonly StylusPointProperty NormalPressure = + new StylusPointProperty(StylusPointPropertyIds.NormalPressure, false); + + /// + /// TangentPressure + /// + public static readonly StylusPointProperty TangentPressure = + new StylusPointProperty(StylusPointPropertyIds.TangentPressure, false); + + /// + /// ButtonPressure + /// + public static readonly StylusPointProperty ButtonPressure = + new StylusPointProperty(StylusPointPropertyIds.ButtonPressure, false); + + /// + /// XTiltOrientation + /// + public static readonly StylusPointProperty XTiltOrientation = + new StylusPointProperty(StylusPointPropertyIds.XTiltOrientation, false); + + /// + /// YTiltOrientation + /// + public static readonly StylusPointProperty YTiltOrientation = + new StylusPointProperty(StylusPointPropertyIds.YTiltOrientation, false); + + /// + /// AzimuthOrientation + /// + public static readonly StylusPointProperty AzimuthOrientation = + new StylusPointProperty(StylusPointPropertyIds.AzimuthOrientation, false); + + /// + /// AltitudeOrientation + /// + public static readonly StylusPointProperty AltitudeOrientation = + new StylusPointProperty(StylusPointPropertyIds.AltitudeOrientation, false); + + /// + /// TwistOrientation + /// + public static readonly StylusPointProperty TwistOrientation = + new StylusPointProperty(StylusPointPropertyIds.TwistOrientation, false); + + /// + /// PitchRotation + /// + public static readonly StylusPointProperty PitchRotation = + new StylusPointProperty(StylusPointPropertyIds.PitchRotation, false); + + /// + /// RollRotation + /// + public static readonly StylusPointProperty RollRotation = + new StylusPointProperty(StylusPointPropertyIds.RollRotation, false); + + /// + /// YawRotation + /// + public static readonly StylusPointProperty YawRotation = + new StylusPointProperty(StylusPointPropertyIds.YawRotation, false); + + /// + /// TipButton + /// + public static readonly StylusPointProperty TipButton = + new StylusPointProperty(StylusPointPropertyIds.TipButton, true); + + /// + /// BarrelButton + /// + public static readonly StylusPointProperty BarrelButton = + new StylusPointProperty(StylusPointPropertyIds.BarrelButton, true); + + /// + /// SecondaryTipButton + /// + public static readonly StylusPointProperty SecondaryTipButton = + new StylusPointProperty(StylusPointPropertyIds.SecondaryTipButton, true); + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointProperty.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointProperty.cs new file mode 100644 index 0000000..6dd382d --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointProperty.cs @@ -0,0 +1,101 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Globalization; +using WpfInk.PresentationCore.System.Windows.Ink; + +namespace WpfInk.PresentationCore.System.Windows.Input.Stylus +{ + /// + /// StylusPointProperty + /// + internal class StylusPointProperty + { + /// + /// Instance data + /// + private Guid _id; + private bool _isButton; + + /// + /// StylusPointProperty + /// + /// identifier + /// isButton + public StylusPointProperty(Guid identifier, bool isButton) + { + Initialize(identifier, isButton); + } + + /// + /// StylusPointProperty + /// + /// + /// Protected - used by the StylusPointPropertyInfo ctor + protected StylusPointProperty(StylusPointProperty stylusPointProperty) + { + ArgumentNullException.ThrowIfNull(stylusPointProperty); + Initialize(stylusPointProperty.Id, stylusPointProperty.IsButton); + } + + /// + /// Common ctor helper + /// + /// identifier + /// isButton + private void Initialize(Guid identifier, bool isButton) + { + // + // validate isButton for known guids + // + if (StylusPointPropertyIds.IsKnownButton(identifier)) + { + if (!isButton) + { + //error, this is a known button + throw new ArgumentException(SR.InvalidIsButtonForId, nameof(isButton)); + } + } + else + { + if (StylusPointPropertyIds.IsKnownId(identifier) && isButton) + { + //error, this is a known guid that is NOT a button + throw new ArgumentException(SR.InvalidIsButtonForId2, nameof(isButton)); + } + } + + _id = identifier; + _isButton = isButton; + } + + /// + /// Id + /// + public Guid Id + { + get { return _id; } + } + + /// + /// IsButton + /// + public bool IsButton + { + get { return _isButton; } + } + + /// + /// Returns a human readable string representation + /// + public override string ToString() + { + return "{Id=" + + StylusPointPropertyIds.GetStringRepresentation(_id) + + ", IsButton=" + + _isButton.ToString(CultureInfo.InvariantCulture) + + "}"; + } + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointPropertyId.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointPropertyId.cs new file mode 100644 index 0000000..80af7ae --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointPropertyId.cs @@ -0,0 +1,416 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +using System; +using System.Windows; +using System.Windows.Input; +using System.Collections.Generic; + +namespace WpfInk.PresentationCore.System.Windows.Ink +{ + /// + /// StylusPointPropertyIds + /// + /// + internal static class StylusPointPropertyIds + { + #region Property GUIDs + + /// + /// The x-coordinate in the tablet coordinate space. + /// + /// + public static readonly Guid X = new Guid(0x598A6A8F, 0x52C0, 0x4BA0, 0x93, 0xAF, 0xAF, 0x35, 0x74, 0x11, 0xA5, 0x61); + /// + /// The y-coordinate in the tablet coordinate space. + /// + /// + public static readonly Guid Y = new Guid(0xB53F9F75, 0x04E0, 0x4498, 0xA7, 0xEE, 0xC3, 0x0D, 0xBB, 0x5A, 0x90, 0x11); + /// + /// The z-coordinate or distance of the pen tip from the tablet surface. + /// + /// + public static readonly Guid Z = new Guid(0x735ADB30, 0x0EBB, 0x4788, 0xA0, 0xE4, 0x0F, 0x31, 0x64, 0x90, 0x05, 0x5D); + /// + /// The width value of touch on the tablet surface. + /// + /// + public static readonly Guid Width = new Guid(0xbaabe94d, 0x2712, 0x48f5, 0xbe, 0x9d, 0x8f, 0x8b, 0x5e, 0xa0, 0x71, 0x1a); + /// + /// The height value of touch on the tablet surface. + /// + /// + public static readonly Guid Height = new Guid(0xe61858d2, 0xe447, 0x4218, 0x9d, 0x3f, 0x18, 0x86, 0x5c, 0x20, 0x3d, 0xf4); + /// + /// SystemTouch + /// + /// + public static readonly Guid SystemTouch = new Guid(0xe706c804, 0x57f0, 0x4f00, 0x8a, 0x0c, 0x85, 0x3d, 0x57, 0x78, 0x9b, 0xe9); + /// + /// The current status of the pen pointer. + /// + /// + public static readonly Guid PacketStatus = new Guid(0x6E0E07BF, 0xAFE7, 0x4CF7, 0x87, 0xD1, 0xAF, 0x64, 0x46, 0x20, 0x84, 0x18); + /// + /// Identifies the packet. + /// + /// + public static readonly Guid SerialNumber = new Guid(0x78A81B56, 0x0935, 0x4493, 0xBA, 0xAE, 0x00, 0x54, 0x1A, 0x8A, 0x16, 0xC4); + /// + /// Downward pressure of the pen tip on the tablet surface. + /// + /// + public static readonly Guid NormalPressure = new Guid(0x7307502D, 0xF9F4, 0x4E18, 0xB3, 0xF2, 0x2C, 0xE1, 0xB1, 0xA3, 0x61, 0x0C); + /// + /// Diagonal pressure of the pen tip on the tablet surface. + /// + /// + public static readonly Guid TangentPressure = new Guid(0x6DA4488B, 0x5244, 0x41EC, 0x90, 0x5B, 0x32, 0xD8, 0x9A, 0xB8, 0x08, 0x09); + /// + /// Pressure on a pressure sensitive button. + /// + /// + public static readonly Guid ButtonPressure = new Guid(0x8B7FEFC4, 0x96AA, 0x4BFE, 0xAC, 0x26, 0x8A, 0x5F, 0x0B, 0xE0, 0x7B, 0xF5); + /// + /// The x-tilt orientation is the angle between the y,z-plane and the pen and y-axis plane. + /// + /// + public static readonly Guid XTiltOrientation = new Guid(0xA8D07B3A, 0x8BF0, 0x40B0, 0x95, 0xA9, 0xB8, 0x0A, 0x6B, 0xB7, 0x87, 0xBF); + /// + /// The y-tilt orientation is the angle between the x,z-plane and the pen and x-axis plane. + /// + /// + public static readonly Guid YTiltOrientation = new Guid(0x0E932389, 0x1D77, 0x43AF, 0xAC, 0x00, 0x5B, 0x95, 0x0D, 0x6D, 0x4B, 0x2D); + /// + /// Clockwise rotation of the pen about the z axis through a full circular range. + /// + /// + public static readonly Guid AzimuthOrientation = new Guid(0x029123B4, 0x8828, 0x410B, 0xB2, 0x50, 0xA0, 0x53, 0x65, 0x95, 0xE5, 0xDC); + /// + /// Angle between the axis of the pen and the surface of the tablet. + /// + /// + public static readonly Guid AltitudeOrientation = new Guid(0x82DEC5C7, 0xF6BA, 0x4906, 0x89, 0x4F, 0x66, 0xD6, 0x8D, 0xFC, 0x45, 0x6C); + /// + /// Clockwise rotation of the pen about its own axis. + /// + /// + public static readonly Guid TwistOrientation = new Guid(0x0D324960, 0x13B2, 0x41E4, 0xAC, 0xE6, 0x7A, 0xE9, 0xD4, 0x3D, 0x2D, 0x3B); + /// + /// Identifies whether the tip is above or below a horizontal line that is perpendicular to the writing surface. Requires 3D digitizer. + /// + /// + public static readonly Guid PitchRotation = new Guid(0x7F7E57B7, 0xBE37, 0x4BE1, 0xA3, 0x56, 0x7A, 0x84, 0x16, 0x0E, 0x18, 0x93); + /// + /// Clockwise rotation of the pen about its own axis. Requires 3D digitizer. + /// + /// + public static readonly Guid RollRotation = new Guid(0x5D5D5E56, 0x6BA9, 0x4C5B, 0x9F, 0xB0, 0x85, 0x1C, 0x91, 0x71, 0x4E, 0x56); + /// + /// Yaw identifies whether the tip is turning left or right around the center of its horzontal axis (pen is horizontal). Requires 3D digitizer. + /// + /// + public static readonly Guid YawRotation = new Guid(0x6A849980, 0x7C3A, 0x45B7, 0xAA, 0x82, 0x90, 0xA2, 0x62, 0x95, 0x0E, 0x89); + /// + /// Identifies the tip button of a stylus. Used for identifying StylusButtons in StylusPointDescription. + /// + /// + public static readonly Guid TipButton = new Guid(0x39143d3, 0x78cb, 0x449c, 0xa8, 0xe7, 0x67, 0xd1, 0x88, 0x64, 0xc3, 0x32); + /// + /// Identifies the button on the barrel of a stylus. Used for identifying StylusButtons in StylusPointDescription. + /// + /// + public static readonly Guid BarrelButton = new Guid(0xf0720328, 0x663b, 0x418f, 0x85, 0xa6, 0x95, 0x31, 0xae, 0x3e, 0xcd, 0xfa); + /// + /// Identifies the secondary tip barrel button of a stylus. Used for identifying StylusButtons in StylusPointDescription. + /// + /// + public static readonly Guid SecondaryTipButton = new Guid(0x67743782, 0xee5, 0x419a, 0xa1, 0x2b, 0x27, 0x3a, 0x9e, 0xc0, 0x8f, 0x3d); + + #endregion + + #region HID Constants + + /// + /// + /// WM_POINTER stack must parse out HID spec usage pages + /// + /// + internal enum HidUsagePage + { + Undefined = 0x00, + Generic = 0x01, + Simulation = 0x02, + Vr = 0x03, + Sport = 0x04, + Game = 0x05, + Keyboard = 0x07, + Led = 0x08, + Button = 0x09, + Ordinal = 0x0a, + Telephony = 0x0b, + Consumer = 0x0c, + Digitizer = 0x0d, + Unicode = 0x10, + Alphanumeric = 0x14, + BarcodeScanner = 0x8C, + WeighingDevice = 0x8D, + MagneticStripeReader = 0x8E, + CameraControl = 0x90, + MicrosoftBluetoothHandsfree = 0xfff3, + } + + /// + /// + /// + /// WISP pre-parsed these, WM_POINTER stack must do it itself + /// + /// See Stylus\biblio.txt - 1 + /// + /// + internal enum HidUsage + { + TipPressure = 0x30, + X = 0x30, + BarrelPressure = 0x31, + Y = 0x31, + Z = 0x32, + XTilt = 0x3D, + YTilt = 0x3E, + Azimuth = 0x3F, + Altitude = 0x40, + Twist = 0x41, + TipSwitch = 0x42, + SecondaryTipSwitch = 0x43, + BarrelSwitch = 0x44, + TouchConfidence = 0x47, + Width = 0x48, + Height = 0x49, + TransducerSerialNumber = 0x5B, + } + + #endregion + + #region HID Associations + + /// + /// + /// WM_POINTER stack usage preparation based on associations maintained from the legacy WISP based stack + /// + private static Dictionary> _hidToGuidMap = new Dictionary>() + { + { HidUsagePage.Generic, + new Dictionary() + { + { HidUsage.X, X }, + { HidUsage.Y, Y }, + { HidUsage.Z, Z }, + } + }, + { HidUsagePage.Digitizer, + new Dictionary() + { + { HidUsage.Width, Width }, + { HidUsage.Height, Height }, + { HidUsage.TouchConfidence, SystemTouch }, + { HidUsage.TipPressure, NormalPressure }, + { HidUsage.BarrelPressure, ButtonPressure }, + { HidUsage.XTilt, XTiltOrientation }, + { HidUsage.YTilt, YTiltOrientation }, + { HidUsage.Azimuth, AzimuthOrientation }, + { HidUsage.Altitude, AltitudeOrientation }, + { HidUsage.Twist, TwistOrientation }, + { HidUsage.TipSwitch, TipButton }, + { HidUsage.SecondaryTipSwitch, SecondaryTipButton }, + { HidUsage.BarrelSwitch, BarrelButton }, + { HidUsage.TransducerSerialNumber, SerialNumber }, + } + }, + }; + + #endregion + + #region Utility Functions + + /// + /// Retrieves the GUID of the stylus property associated with the usage page and usage ids + /// within the HID specification. + /// + /// The usage page id of the HID specification + /// The usage id of the HID specification + /// + /// If known, the GUID associated with the usagePageId and usageId. + /// If not known, GUID.Empty + /// + internal static Guid GetKnownGuid(HidUsagePage page, HidUsage usage) + { + Guid result = Guid.Empty; + + Dictionary pageMap = null; + + if (_hidToGuidMap.TryGetValue(page, out pageMap)) + { + pageMap.TryGetValue(usage, out result); + } + + return result; + } + + /// + /// Called by the StylusPointProperty constructor. + /// Any new Guids in this static class should be added here + /// + /// guid + internal static bool IsKnownId(Guid guid) + { + if (guid == X || + guid == Y || + guid == Z || + guid == Width || + guid == Height || + guid == SystemTouch || + guid == PacketStatus || + guid == SerialNumber || + guid == NormalPressure || + guid == TangentPressure || + guid == ButtonPressure || + guid == XTiltOrientation || + guid == YTiltOrientation || + guid == AzimuthOrientation || + guid == AltitudeOrientation || + guid == TwistOrientation || + guid == PitchRotation || + guid == RollRotation || + guid == YawRotation || + guid == TipButton || + guid == BarrelButton || + guid == SecondaryTipButton) + { + return true; + } + return false; + } + + /// + /// Called by the StylusPointProperty constructor. + /// Any new Guids in this static class should be added here + /// + /// guid + internal static string GetStringRepresentation(Guid guid) + { + if (guid == X) + { + return "X"; + } + if (guid == Y) + { + return "Y"; + } + if (guid == Z) + { + return "Z"; + } + if (guid == Width) + { + return "Width"; + } + if (guid == Height) + { + return "Height"; + } + if (guid == SystemTouch) + { + return "SystemTouch"; + } + if (guid == PacketStatus) + { + return "PacketStatus"; + } + if (guid == SerialNumber) + { + return "SerialNumber"; + } + if (guid == NormalPressure) + { + return "NormalPressure"; + } + if (guid == TangentPressure) + { + return "TangentPressure"; + } + if (guid == ButtonPressure) + { + return "ButtonPressure"; + } + if (guid == XTiltOrientation) + { + return "XTiltOrientation"; + } + if (guid == YTiltOrientation) + { + return "YTiltOrientation"; + } + if (guid == AzimuthOrientation) + { + return "AzimuthOrientation"; + } + if (guid == AltitudeOrientation) + { + return "AltitudeOrientation"; + } + if (guid == TwistOrientation) + { + return "TwistOrientation"; + } + if (guid == PitchRotation) + { + return "PitchRotation"; + } + if (guid == RollRotation) + { + return "RollRotation"; + } + if (guid == AltitudeOrientation) + { + return "AltitudeOrientation"; + } + if (guid == YawRotation) + { + return "YawRotation"; + } + if (guid == TipButton) + { + return "TipButton"; + } + if (guid == BarrelButton) + { + return "BarrelButton"; + } + if (guid == SecondaryTipButton) + { + return "SecondaryTipButton"; + } + return "Unknown"; + } + + /// + /// Called by the StylusPointProperty constructor. + /// Any new button Guids in this static class should be added here + /// + /// guid + internal static bool IsKnownButton(Guid guid) + { + if (guid == TipButton || + guid == BarrelButton || + guid == SecondaryTipButton) + { + return true; + } + return false; + } + + #endregion + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointPropertyInfo.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointPropertyInfo.cs new file mode 100644 index 0000000..def55f5 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointPropertyInfo.cs @@ -0,0 +1,130 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Windows.Input; +using WpfInk.PresentationCore.System.Windows.Ink; + +namespace WpfInk.PresentationCore.System.Windows.Input.Stylus +{ + /// + /// StylusPointPropertyInfo + /// + internal class StylusPointPropertyInfo : StylusPointProperty + { + /// + /// Instance data + /// + private int _min; + private int _max; + private float _resolution; + private StylusPointPropertyUnit _unit; + + /// + /// For a given StylusPointProperty, instantiates a StylusPointPropertyInfo with default values + /// + /// + public StylusPointPropertyInfo(StylusPointProperty stylusPointProperty) + : base(stylusPointProperty) //base checks for null + { + StylusPointPropertyInfo info = + StylusPointPropertyInfoDefaults.GetStylusPointPropertyInfoDefault(stylusPointProperty); + _min = info.Minimum; + _max = info.Maximum; + _resolution = info.Resolution; + _unit = info.Unit; + } + + /// + /// StylusPointProperty + /// + /// + /// minimum + /// maximum + /// unit + /// resolution + public StylusPointPropertyInfo(StylusPointProperty stylusPointProperty, int minimum, int maximum, StylusPointPropertyUnit unit, float resolution) + : base(stylusPointProperty) //base checks for null + { + // validate unit + if (!StylusPointPropertyUnitHelper.IsDefined(unit)) + { + throw new InvalidEnumArgumentException("unit", (int) unit, typeof(StylusPointPropertyUnit)); + } + + // validate min/max + if (maximum < minimum) + { + throw new ArgumentException(SR.Stylus_InvalidMax, nameof(maximum)); + } + + // validate resolution + if (resolution < 0.0f) + { + throw new ArgumentException(SR.InvalidStylusPointPropertyInfoResolution, nameof(resolution)); + } + + _min = minimum; + _max = maximum; + _resolution = resolution; + _unit = unit; + } + + /// + /// Minimum + /// + public int Minimum + { + get { return _min; } + } + + /// + /// Maximum + /// + public int Maximum + { + get { return _max; } + } + + /// + /// Resolution + /// + public float Resolution + { + get { return _resolution; } + internal set { _resolution = value; } + } + + /// + /// Unit + /// + public StylusPointPropertyUnit Unit + { + get { return _unit; } + } + + /// + /// Internal helper method for comparing compat for two StylusPointPropertyInfos + /// + internal static bool AreCompatible(StylusPointPropertyInfo stylusPointPropertyInfo1, StylusPointPropertyInfo stylusPointPropertyInfo2) + { + if (stylusPointPropertyInfo1 == null || stylusPointPropertyInfo2 == null) + { + throw new ArgumentNullException("stylusPointPropertyInfo"); + } + + Debug.Assert((stylusPointPropertyInfo1.Id != StylusPointPropertyIds.X && + stylusPointPropertyInfo1.Id != StylusPointPropertyIds.Y && + stylusPointPropertyInfo2.Id != StylusPointPropertyIds.X && + stylusPointPropertyInfo2.Id != StylusPointPropertyIds.Y), + "Why are you checking X, Y for compatibility? They're always compatible"); + // + // we only take ID and IsButton into account, we don't take metrics into account + // + return (stylusPointPropertyInfo1.Id == stylusPointPropertyInfo2.Id && + stylusPointPropertyInfo1.IsButton == stylusPointPropertyInfo2.IsButton); + } + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointPropertyInfoDefaults.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointPropertyInfoDefaults.cs new file mode 100644 index 0000000..6c8e0cd --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointPropertyInfoDefaults.cs @@ -0,0 +1,363 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +using System; +using System.Windows; +using System.Windows.Input; +using System.Collections.Generic; +using WpfInk.PresentationCore.System.Windows.Input.Stylus; + +namespace WpfInk.PresentationCore.System.Windows.Ink +{ + internal static class StylusPointPropertyInfoDefaults + { + /// + /// X + /// + internal static readonly StylusPointPropertyInfo X = + new StylusPointPropertyInfo(StylusPointProperties.X, + Int32.MinValue, + Int32.MaxValue, + StylusPointPropertyUnit.Centimeters, + 1000.0f); + + /// + /// Y + /// + internal static readonly StylusPointPropertyInfo Y = + new StylusPointPropertyInfo(StylusPointProperties.Y, + Int32.MinValue, + Int32.MaxValue, + StylusPointPropertyUnit.Centimeters, + 1000.0f); + + /// + /// Z + /// + internal static readonly StylusPointPropertyInfo Z = + new StylusPointPropertyInfo(StylusPointProperties.Z, + Int32.MinValue, + Int32.MaxValue, + StylusPointPropertyUnit.Centimeters, + 1000.0f); + + /// + /// Width + /// + internal static readonly StylusPointPropertyInfo Width = + new StylusPointPropertyInfo(StylusPointProperties.Width, + Int32.MinValue, + Int32.MaxValue, + StylusPointPropertyUnit.Centimeters, + 1000.0f); + + /// + /// Height + /// + internal static readonly StylusPointPropertyInfo Height = + new StylusPointPropertyInfo(StylusPointProperties.Height, + Int32.MinValue, + Int32.MaxValue, + StylusPointPropertyUnit.Centimeters, + 1000.0f); + + /// + /// SystemTouch + /// + internal static readonly StylusPointPropertyInfo SystemTouch = + new StylusPointPropertyInfo(StylusPointProperties.SystemTouch, + 0, + 1, + StylusPointPropertyUnit.None, + 1.0f); + + /// + /// PacketStatus + /// + internal static readonly StylusPointPropertyInfo PacketStatus = + new StylusPointPropertyInfo(StylusPointProperties.PacketStatus, + Int32.MinValue, + Int32.MaxValue, + StylusPointPropertyUnit.None, + 1.0f); + + /// + /// SerialNumber + /// + /// + internal static readonly StylusPointPropertyInfo SerialNumber = + new StylusPointPropertyInfo(StylusPointProperties.SerialNumber, + Int32.MinValue, + Int32.MaxValue, + StylusPointPropertyUnit.None, + 1.0f); + + /// + /// NormalPressure + /// + internal static readonly StylusPointPropertyInfo NormalPressure = + new StylusPointPropertyInfo(StylusPointProperties.NormalPressure, + 0, + 1023, + StylusPointPropertyUnit.None, + 1.0f); + + /// + /// TangentPressure + /// + internal static readonly StylusPointPropertyInfo TangentPressure = + new StylusPointPropertyInfo(StylusPointProperties.TangentPressure, + 0, + 1023, + StylusPointPropertyUnit.None, + 1.0f); + + /// + /// ButtonPressure + /// + internal static readonly StylusPointPropertyInfo ButtonPressure = + new StylusPointPropertyInfo(StylusPointProperties.ButtonPressure, + 0, + 1023, + StylusPointPropertyUnit.None, + 1.0f); + + /// + /// XTiltOrientation + /// + internal static readonly StylusPointPropertyInfo XTiltOrientation = + new StylusPointPropertyInfo(StylusPointProperties.XTiltOrientation, + 0, + 3600, + StylusPointPropertyUnit.Degrees, + 10.0f); + + /// + /// YTiltOrientation + /// + internal static readonly StylusPointPropertyInfo YTiltOrientation = + new StylusPointPropertyInfo(StylusPointProperties.YTiltOrientation, + 0, + 3600, + StylusPointPropertyUnit.Degrees, + 10.0f); + + /// + /// AzimuthOrientation + /// + internal static readonly StylusPointPropertyInfo AzimuthOrientation = + new StylusPointPropertyInfo(StylusPointProperties.AzimuthOrientation, + 0, + 3600, + StylusPointPropertyUnit.Degrees, + 10.0f); + + /// + /// AltitudeOrientation + /// + internal static readonly StylusPointPropertyInfo AltitudeOrientation = + new StylusPointPropertyInfo(StylusPointProperties.AltitudeOrientation, + -900, + 900, + StylusPointPropertyUnit.Degrees, + 10.0f); + + /// + /// TwistOrientation + /// + internal static readonly StylusPointPropertyInfo TwistOrientation = + new StylusPointPropertyInfo(StylusPointProperties.TwistOrientation, + 0, + 3600, + StylusPointPropertyUnit.Degrees, + 10.0f); + + /// + /// PitchRotation + /// + internal static readonly StylusPointPropertyInfo PitchRotation = + new StylusPointPropertyInfo(StylusPointProperties.PitchRotation, + Int32.MinValue, + Int32.MaxValue, + StylusPointPropertyUnit.None, + 1.0f); + + /// + /// RollRotation + /// + internal static readonly StylusPointPropertyInfo RollRotation = + new StylusPointPropertyInfo(StylusPointProperties.RollRotation, + Int32.MinValue, + Int32.MaxValue, + StylusPointPropertyUnit.None, + 1.0f); + + /// + /// YawRotation + /// + internal static readonly StylusPointPropertyInfo YawRotation = + new StylusPointPropertyInfo(StylusPointProperties.YawRotation, + Int32.MinValue, + Int32.MaxValue, + StylusPointPropertyUnit.None, + 1.0f); + + /// + /// TipButton + /// + internal static readonly StylusPointPropertyInfo TipButton = + new StylusPointPropertyInfo(StylusPointProperties.TipButton, + 0, + 1, + StylusPointPropertyUnit.None, + 1.0f); + + /// + /// BarrelButton + /// + internal static readonly StylusPointPropertyInfo BarrelButton = + new StylusPointPropertyInfo(StylusPointProperties.BarrelButton, + 0, + 1, + StylusPointPropertyUnit.None, + 1.0f); + + /// + /// SecondaryTipButton + /// + internal static readonly StylusPointPropertyInfo SecondaryTipButton = + new StylusPointPropertyInfo(StylusPointProperties.SecondaryTipButton, + 0, + 1, + StylusPointPropertyUnit.None, + 1.0f); + + /// + /// Default Value + /// + internal static readonly StylusPointPropertyInfo DefaultValue = + new StylusPointPropertyInfo(new StylusPointProperty(Guid.NewGuid(), false), + Int32.MinValue, + Int32.MaxValue, + StylusPointPropertyUnit.None, + 1.0F); + + /// + /// DefaultButton + /// + internal static readonly StylusPointPropertyInfo DefaultButton = + new StylusPointPropertyInfo(new StylusPointProperty(Guid.NewGuid(), true), + 0, + 1, + StylusPointPropertyUnit.None, + 1.0f); + + /// + /// For a given StylusPointProperty, return the default property info + /// + /// stylusPointProperty + /// + internal static StylusPointPropertyInfo GetStylusPointPropertyInfoDefault(StylusPointProperty stylusPointProperty) + { + if (stylusPointProperty.Id == StylusPointPropertyIds.X) + { + return StylusPointPropertyInfoDefaults.X; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.Y) + { + return StylusPointPropertyInfoDefaults.Y; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.Z) + { + return StylusPointPropertyInfoDefaults.Z; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.Width) + { + return StylusPointPropertyInfoDefaults.Width; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.Height) + { + return StylusPointPropertyInfoDefaults.Height; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.SystemTouch) + { + return StylusPointPropertyInfoDefaults.SystemTouch; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.PacketStatus) + { + return StylusPointPropertyInfoDefaults.PacketStatus; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.SerialNumber) + { + return StylusPointPropertyInfoDefaults.SerialNumber; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.NormalPressure) + { + return StylusPointPropertyInfoDefaults.NormalPressure; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.TangentPressure) + { + return StylusPointPropertyInfoDefaults.TangentPressure; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.ButtonPressure) + { + return StylusPointPropertyInfoDefaults.ButtonPressure; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.XTiltOrientation) + { + return StylusPointPropertyInfoDefaults.XTiltOrientation; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.YTiltOrientation) + { + return StylusPointPropertyInfoDefaults.YTiltOrientation; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.AzimuthOrientation) + { + return StylusPointPropertyInfoDefaults.AzimuthOrientation; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.AltitudeOrientation) + { + return StylusPointPropertyInfoDefaults.AltitudeOrientation; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.TwistOrientation) + { + return StylusPointPropertyInfoDefaults.TwistOrientation; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.PitchRotation) + { + return StylusPointPropertyInfoDefaults.PitchRotation; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.RollRotation) + { + return StylusPointPropertyInfoDefaults.RollRotation; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.YawRotation) + { + return StylusPointPropertyInfoDefaults.YawRotation; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.TipButton) + { + return StylusPointPropertyInfoDefaults.TipButton; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.BarrelButton) + { + return StylusPointPropertyInfoDefaults.BarrelButton; + } + if (stylusPointProperty.Id == StylusPointPropertyIds.SecondaryTipButton) + { + return StylusPointPropertyInfoDefaults.SecondaryTipButton; + } + + // + // return a default + // + if (stylusPointProperty.IsButton) + { + return StylusPointPropertyInfoDefaults.DefaultButton; + } + return StylusPointPropertyInfoDefaults.DefaultValue; + } + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointPropertyUnit.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointPropertyUnit.cs new file mode 100644 index 0000000..a723eaa --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Input/Stylus/StylusPointPropertyUnit.cs @@ -0,0 +1,102 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace WpfInk.PresentationCore.System.Windows.Input.Stylus +{ + /// + /// Stylus data is made up of n number of properties. Each property can contain one or more + /// values such as x or y coordinate or button states. + /// This enum defines the various possible units for the values in the stylus data + /// + /// + internal enum StylusPointPropertyUnit + { + /// Specifies that the units are unknown. + /// + None = 0, + /// Specifies that the property value is in inches (distance units). + /// + Inches = 1, + /// Specifies that the property value is in centimeters (distance units). + /// + Centimeters = 2, + /// Specifies that the property value is in degrees (angle units). + /// + Degrees = 3, + /// Specifies that the property value is in radians (angle units). + /// + Radians = 4, + /// Specifies that the property value is in seconds (angle units). + /// + Seconds = 5, + /// + /// Specifies that the property value is in pounds (force, or mass, units). + Pounds = 6, + /// + /// Specifies that the property value is in grams (force, or mass, units). + Grams = 7 + } + + /// + /// Used to validate the enum + /// + /// + /// Added various functions to support WM_POINTER based stack + /// + internal static class StylusPointPropertyUnitHelper + { + #region Constants + + /// + /// Mask to extract units from raw WM_POINTER data + /// + /// + private const uint UNIT_MASK = 0x000F; + + #endregion + + #region Conversion Maps + + /// + /// Mapping for WM_POINTER based unit, taken from legacy WISP code + /// + private static Dictionary _pointerUnitMap = new Dictionary() + { + { 1, StylusPointPropertyUnit.Centimeters }, + { 2, StylusPointPropertyUnit.Radians }, + { 3, StylusPointPropertyUnit.Inches }, + { 4, StylusPointPropertyUnit.Degrees }, + }; + + #endregion + + #region Utility Functions + + /// + /// Convert WM_POINTER units to WPF units + /// + /// + /// + internal static StylusPointPropertyUnit? FromPointerUnit(uint pointerUnit) + { + StylusPointPropertyUnit unit = StylusPointPropertyUnit.None; + + _pointerUnitMap.TryGetValue(pointerUnit & UNIT_MASK, out unit); + + return (IsDefined(unit)) ? unit : (StylusPointPropertyUnit?) null; + } + + internal static bool IsDefined(StylusPointPropertyUnit unit) + { + if (unit >= StylusPointPropertyUnit.None && unit <= StylusPointPropertyUnit.Grams) + { + return true; + } + return false; + } + + #endregion + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Point.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Point.cs new file mode 100644 index 0000000..ac80e78 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Point.cs @@ -0,0 +1,418 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// +// +// This file was generated, please do not edit it directly. +// +// Please see MilCodeGen.html for more information. +// + + +//using System.Windows.Converters; + +using System; + +namespace WpfInk.PresentationCore.System.Windows +{ + internal struct Point + { + //------------------------------------------------------ + // + // Public Methods + // + //------------------------------------------------------ + + #region Public Methods + + + + + /// + /// Compares two Point instances for exact equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which are logically equal may fail. + /// Furthermore, using this equality operator, Double.NaN is not equal to itself. + /// + /// + /// bool - true if the two Point instances are exactly equal, false otherwise + /// + /// The first Point to compare + /// The second Point to compare + public static bool operator ==(Point point1, Point point2) + { + return point1.X == point2.X && + point1.Y == point2.Y; + } + + /// + /// Compares two Point instances for exact inequality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which are logically equal may fail. + /// Furthermore, using this equality operator, Double.NaN is not equal to itself. + /// + /// + /// bool - true if the two Point instances are exactly unequal, false otherwise + /// + /// The first Point to compare + /// The second Point to compare + public static bool operator !=(Point point1, Point point2) + { + return !(point1 == point2); + } + /// + /// Compares two Point instances for object equality. In this equality + /// Double.NaN is equal to itself, unlike in numeric equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which + /// are logically equal may fail. + /// + /// + /// bool - true if the two Point instances are exactly equal, false otherwise + /// + /// The first Point to compare + /// The second Point to compare + public static bool Equals(Point point1, Point point2) + { + return point1.X.Equals(point2.X) && + point1.Y.Equals(point2.Y); + } + + /// + /// Equals - compares this Point with the passed in object. In this equality + /// Double.NaN is equal to itself, unlike in numeric equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which + /// are logically equal may fail. + /// + /// + /// bool - true if the object is an instance of Point and if it's equal to "this". + /// + /// The object to compare to "this" + public override bool Equals(object o) + { + if ((null == o) || !(o is Point)) + { + return false; + } + + Point value = (Point) o; + return Point.Equals(this, value); + } + + /// + /// Equals - compares this Point with the passed in object. In this equality + /// Double.NaN is equal to itself, unlike in numeric equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which + /// are logically equal may fail. + /// + /// + /// bool - true if "value" is equal to "this". + /// + /// The Point to compare to "this" + public bool Equals(Point value) + { + return Point.Equals(this, value); + } + /// + /// Returns the HashCode for this Point + /// + /// + /// int - the HashCode for this Point + /// + public override int GetHashCode() + { + // Perform field-by-field XOR of HashCodes + return X.GetHashCode() ^ + Y.GetHashCode(); + } + + + + #endregion Public Methods + + //------------------------------------------------------ + // + // Public Properties + // + //------------------------------------------------------ + + + + + #region Public Properties + + /// + /// X - double. Default value is 0. + /// + public double X + { + get + { + return _x; + } + + set + { + _x = value; + } + + } + + /// + /// Y - double. Default value is 0. + /// + public double Y + { + get + { + return _y; + } + + set + { + _y = value; + } + + } + + #endregion Public Properties + + //------------------------------------------------------ + // + // Protected Methods + // + //------------------------------------------------------ + + #region Protected Methods + + + + + + #endregion ProtectedMethods + + //------------------------------------------------------ + // + // Internal Methods + // + //------------------------------------------------------ + + #region Internal Methods + + + + + + + + + + #endregion Internal Methods + + //------------------------------------------------------ + // + // Internal Properties + // + //------------------------------------------------------ + + #region Internal Properties + + + /// + /// Creates a string representation of this object based on the current culture. + /// + /// + /// A string representation of this object. + /// + public override string ToString() + { + return $"({X},{Y})"; + } + + + + + #endregion Internal Properties + + //------------------------------------------------------ + // + // Dependency Properties + // + //------------------------------------------------------ + + #region Dependency Properties + + + + #endregion Dependency Properties + + //------------------------------------------------------ + // + // Internal Fields + // + //------------------------------------------------------ + + #region Internal Fields + + + internal double _x; + internal double _y; + + + + + #endregion Internal Fields + + + + #region Constructors + + //------------------------------------------------------ + // + // Constructors + // + //------------------------------------------------------ + + + + + #endregion Constructors + + #region Constructors + + /// + /// Constructor which accepts the X and Y values + /// + /// The value for the X coordinate of the new Point + /// The value for the Y coordinate of the new Point + public Point(double x, double y) + { + _x = x; + _y = y; + } + + #endregion Constructors + + #region Public Methods + + /// + /// Offset - update the location by adding offsetX to X and offsetY to Y + /// + /// The offset in the x dimension + /// The offset in the y dimension + public void Offset(double offsetX, double offsetY) + { + _x += offsetX; + _y += offsetY; + } + + /// + /// Operator Point + Vector + /// + /// + /// Point - The result of the addition + /// + /// The Point to be added to the Vector + /// The Vectr to be added to the Point + public static Point operator +(Point point, Vector vector) + { + return new Point(point._x + vector._x, point._y + vector._y); + } + + /// + /// Add: Point + Vector + /// + /// + /// Point - The result of the addition + /// + /// The Point to be added to the Vector + /// The Vector to be added to the Point + public static Point Add(Point point, Vector vector) + { + return new Point(point._x + vector._x, point._y + vector._y); + } + + /// + /// Operator Point - Vector + /// + /// + /// Point - The result of the subtraction + /// + /// The Point from which the Vector is subtracted + /// The Vector which is subtracted from the Point + public static Point operator -(Point point, Vector vector) + { + return new Point(point._x - vector._x, point._y - vector._y); + } + + /// + /// Subtract: Point - Vector + /// + /// + /// Point - The result of the subtraction + /// + /// The Point from which the Vector is subtracted + /// The Vector which is subtracted from the Point + public static Point Subtract(Point point, Vector vector) + { + return new Point(point._x - vector._x, point._y - vector._y); + } + + /// + /// Operator Point - Point + /// + /// + /// Vector - The result of the subtraction + /// + /// The Point from which point2 is subtracted + /// The Point subtracted from point1 + public static Vector operator -(Point point1, Point point2) + { + return new Vector(point1._x - point2._x, point1._y - point2._y); + } + + /// + /// Subtract: Point - Point + /// + /// + /// Vector - The result of the subtraction + /// + /// The Point from which point2 is subtracted + /// The Point subtracted from point1 + public static Vector Subtract(Point point1, Point point2) + { + return new Vector(point1._x - point2._x, point1._y - point2._y); + } + + + + /// + /// Explicit conversion to Size. Note that since Size cannot contain negative values, + /// the resulting size will contains the absolute values of X and Y + /// + /// + /// Size - A Size equal to this Point + /// + /// Point - the Point to convert to a Size + public static explicit operator Size(Point point) + { + return new Size(Math.Abs(point._x), Math.Abs(point._y)); + } + + /// + /// Explicit conversion to Vector + /// + /// + /// Vector - A Vector equal to this Point + /// + /// Point - the Point to convert to a Vector + public static explicit operator Vector(Point point) + { + return new Vector(point._x, point._y); + } + + #endregion Public Methods + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Rect.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Rect.cs new file mode 100644 index 0000000..1963f82 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Rect.cs @@ -0,0 +1,1111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; + +namespace WpfInk.PresentationCore.System.Windows +{ + internal struct Rect + { + #region Constructors + + /// + /// Constructor which sets the initial values to the values of the parameters + /// + public Rect(Point location, + Size size) + { + if (size.IsEmpty) + { + this = s_empty; + } + else + { + _x = location._x; + _y = location._y; + _width = size._width; + _height = size._height; + } + } + + /// + /// Constructor which sets the initial values to the values of the parameters. + /// Width and Height must be non-negative + /// + public Rect(double x, + double y, + double width, + double height) + { + if (width < 0 || height < 0) + { + throw new global::System.ArgumentException(SR.Size_WidthAndHeightCannotBeNegative); + } + + _x = x; + _y = y; + _width = width; + _height = height; + } + + /// + /// Constructor which sets the initial values to bound the two points provided. + /// + public Rect(Point point1, + Point point2) + { + _x = Math.Min(point1._x, point2._x); + _y = Math.Min(point1._y, point2._y); + + // Max with 0 to prevent double weirdness from causing us to be (-epsilon..0) + _width = Math.Max(Math.Max(point1._x, point2._x) - _x, 0); + _height = Math.Max(Math.Max(point1._y, point2._y) - _y, 0); + } + + /// + /// Constructor which sets the initial values to bound the point provided and the point + /// which results from point + vector. + /// + public Rect(Point point, + Vector vector) : this(point, point + vector) + { + } + + /// + /// Constructor which sets the initial values to bound the (0,0) point and the point + /// that results from (0,0) + size. + /// + public Rect(Size size) + { + if (size.IsEmpty) + { + this = s_empty; + } + else + { + _x = _y = 0; + _width = size.Width; + _height = size.Height; + } + } + + #endregion Constructors + + #region Statics + + /// + /// Empty - a static property which provides an Empty rectangle. X and Y are positive-infinity + /// and Width and Height are negative infinity. This is the only situation where Width or + /// Height can be negative. + /// + public static Rect Empty + { + get + { + return s_empty; + } + } + + #endregion Statics + + #region Public Properties + + /// + /// IsEmpty - this returns true if this rect is the Empty rectangle. + /// Note: If width or height are 0 this Rectangle still contains a 0 or 1 dimensional set + /// of points, so this method should not be used to check for 0 area. + /// + public bool IsEmpty + { + get + { + // The funny width and height tests are to handle NaNs + Debug.Assert((!(_width < 0) && !(_height < 0)) || (this == Empty)); + + return _width < 0; + } + } + + /// + /// Location - The Point representing the origin of the Rectangle + /// + public Point Location + { + get + { + return new Point(_x, _y); + } + set + { + if (IsEmpty) + { + throw new global::System.InvalidOperationException(SR.Rect_CannotModifyEmptyRect); + } + + _x = value._x; + _y = value._y; + } + } + + /// + /// Size - The Size representing the area of the Rectangle + /// + public Size Size + { + get + { + if (IsEmpty) + return Size.Empty; + return new Size(_width, _height); + } + set + { + if (value.IsEmpty) + { + this = s_empty; + } + else + { + if (IsEmpty) + { + throw new global::System.InvalidOperationException(SR.Rect_CannotModifyEmptyRect); + } + + _width = value._width; + _height = value._height; + } + } + } + + /// + /// X - The X coordinate of the Location. + /// If this is the empty rectangle, the value will be positive infinity. + /// If this rect is Empty, setting this property is illegal. + /// + public double X + { + get + { + return _x; + } + set + { + if (IsEmpty) + { + throw new global::System.InvalidOperationException(SR.Rect_CannotModifyEmptyRect); + } + + _x = value; + } + } + + /// + /// Y - The Y coordinate of the Location + /// If this is the empty rectangle, the value will be positive infinity. + /// If this rect is Empty, setting this property is illegal. + /// + public double Y + { + get + { + return _y; + } + set + { + if (IsEmpty) + { + throw new global::System.InvalidOperationException(SR.Rect_CannotModifyEmptyRect); + } + + _y = value; + } + } + + /// + /// Width - The Width component of the Size. This cannot be set to negative, and will only + /// be negative if this is the empty rectangle, in which case it will be negative infinity. + /// If this rect is Empty, setting this property is illegal. + /// + public double Width + { + get + { + return _width; + } + set + { + if (IsEmpty) + { + throw new global::System.InvalidOperationException(SR.Rect_CannotModifyEmptyRect); + } + + if (value < 0) + { + throw new global::System.ArgumentException(SR.Size_WidthCannotBeNegative); + } + + _width = value; + } + } + + /// + /// Height - The Height component of the Size. This cannot be set to negative, and will only + /// be negative if this is the empty rectangle, in which case it will be negative infinity. + /// If this rect is Empty, setting this property is illegal. + /// + public double Height + { + get + { + return _height; + } + set + { + if (IsEmpty) + { + throw new global::System.InvalidOperationException(SR.Rect_CannotModifyEmptyRect); + } + + if (value < 0) + { + throw new global::System.ArgumentException(SR.Size_HeightCannotBeNegative); + } + + _height = value; + } + } + + /// + /// Left Property - This is a read-only alias for X + /// If this is the empty rectangle, the value will be positive infinity. + /// + public double Left + { + get + { + return _x; + } + } + + /// + /// Top Property - This is a read-only alias for Y + /// If this is the empty rectangle, the value will be positive infinity. + /// + public double Top + { + get + { + return _y; + } + } + + /// + /// Right Property - This is a read-only alias for X + Width + /// If this is the empty rectangle, the value will be negative infinity. + /// + public double Right + { + get + { + if (IsEmpty) + { + return Double.NegativeInfinity; + } + + return _x + _width; + } + } + + /// + /// Bottom Property - This is a read-only alias for Y + Height + /// If this is the empty rectangle, the value will be negative infinity. + /// + public double Bottom + { + get + { + if (IsEmpty) + { + return Double.NegativeInfinity; + } + + return _y + _height; + } + } + + /// + /// TopLeft Property - This is a read-only alias for the Point which is at X, Y + /// If this is the empty rectangle, the value will be positive infinity, positive infinity. + /// + public Point TopLeft + { + get + { + return new Point(Left, Top); + } + } + + /// + /// TopRight Property - This is a read-only alias for the Point which is at X + Width, Y + /// If this is the empty rectangle, the value will be negative infinity, positive infinity. + /// + public Point TopRight + { + get + { + return new Point(Right, Top); + } + } + + /// + /// BottomLeft Property - This is a read-only alias for the Point which is at X, Y + Height + /// If this is the empty rectangle, the value will be positive infinity, negative infinity. + /// + public Point BottomLeft + { + get + { + return new Point(Left, Bottom); + } + } + + /// + /// BottomRight Property - This is a read-only alias for the Point which is at X + Width, Y + Height + /// If this is the empty rectangle, the value will be negative infinity, negative infinity. + /// + public Point BottomRight + { + get + { + return new Point(Right, Bottom); + } + } + #endregion Public Properties + + #region Public Methods + + /// + /// Contains - Returns true if the Point is within the rectangle, inclusive of the edges. + /// Returns false otherwise. + /// + /// The point which is being tested + /// + /// Returns true if the Point is within the rectangle. + /// Returns false otherwise + /// + public bool Contains(Point point) + { + return Contains(point._x, point._y); + } + + /// + /// Contains - Returns true if the Point represented by x,y is within the rectangle inclusive of the edges. + /// Returns false otherwise. + /// + /// X coordinate of the point which is being tested + /// Y coordinate of the point which is being tested + /// + /// Returns true if the Point represented by x,y is within the rectangle. + /// Returns false otherwise. + /// + public bool Contains(double x, double y) + { + if (IsEmpty) + { + return false; + } + + return ContainsInternal(x, y); + } + + /// + /// Contains - Returns true if the Rect non-Empty and is entirely contained within the + /// rectangle, inclusive of the edges. + /// Returns false otherwise + /// + public bool Contains(Rect rect) + { + if (IsEmpty || rect.IsEmpty) + { + return false; + } + + return (_x <= rect._x && + _y <= rect._y && + _x + _width >= rect._x + rect._width && + _y + _height >= rect._y + rect._height); + } + + /// + /// IntersectsWith - Returns true if the Rect intersects with this rectangle + /// Returns false otherwise. + /// Note that if one edge is coincident, this is considered an intersection. + /// + /// + /// Returns true if the Rect intersects with this rectangle + /// Returns false otherwise. + /// or Height + /// + /// Rect + public bool IntersectsWith(Rect rect) + { + if (IsEmpty || rect.IsEmpty) + { + return false; + } + + return (rect.Left <= Right) && + (rect.Right >= Left) && + (rect.Top <= Bottom) && + (rect.Bottom >= Top); + } + + /// + /// Intersect - Update this rectangle to be the intersection of this and rect + /// If either this or rect are Empty, the result is Empty as well. + /// + /// The rect to intersect with this + public void Intersect(Rect rect) + { + if (!this.IntersectsWith(rect)) + { + this = Empty; + } + else + { + double left = Math.Max((double) Left, rect.Left); + double top = Math.Max((double) Top, rect.Top); + + // Max with 0 to prevent double weirdness from causing us to be (-epsilon..0) + _width = Math.Max(Math.Min((double) Right, rect.Right) - left, 0); + _height = Math.Max(Math.Min((double) Bottom, rect.Bottom) - top, 0); + + _x = left; + _y = top; + } + } + + /// + /// Intersect - Return the result of the intersection of rect1 and rect2. + /// If either this or rect are Empty, the result is Empty as well. + /// + public static Rect Intersect(Rect rect1, Rect rect2) + { + rect1.Intersect(rect2); + return rect1; + } + + /// + /// Union - Update this rectangle to be the union of this and rect. + /// + public void Union(Rect rect) + { + if (IsEmpty) + { + this = rect; + } + else if (!rect.IsEmpty) + { + double left = Math.Min((double) Left, rect.Left); + double top = Math.Min((double) Top, rect.Top); + + + // We need this check so that the math does not result in NaN + if ((rect.Width == Double.PositiveInfinity) || (Width == Double.PositiveInfinity)) + { + _width = Double.PositiveInfinity; + } + else + { + // Max with 0 to prevent double weirdness from causing us to be (-epsilon..0) + double maxRight = Math.Max((double) Right, rect.Right); + _width = Math.Max(maxRight - left, 0); + } + + // We need this check so that the math does not result in NaN + if ((rect.Height == Double.PositiveInfinity) || (Height == Double.PositiveInfinity)) + { + _height = Double.PositiveInfinity; + } + else + { + // Max with 0 to prevent double weirdness from causing us to be (-epsilon..0) + double maxBottom = Math.Max((double) Bottom, rect.Bottom); + _height = Math.Max(maxBottom - top, 0); + } + + _x = left; + _y = top; + } + } + + /// + /// Union - Return the result of the union of rect1 and rect2. + /// + public static Rect Union(Rect rect1, Rect rect2) + { + rect1.Union(rect2); + return rect1; + } + + /// + /// Union - Update this rectangle to be the union of this and point. + /// + public void Union(Point point) + { + Union(new Rect(point, point)); + } + + /// + /// Union - Return the result of the union of rect and point. + /// + public static Rect Union(Rect rect, Point point) + { + rect.Union(new Rect(point, point)); + return rect; + } + + /// + /// Offset - translate the Location by the offset provided. + /// If this is Empty, this method is illegal. + /// + public void Offset(Vector offsetVector) + { + if (IsEmpty) + { + throw new global::System.InvalidOperationException(SR.Rect_CannotCallMethod); + } + + _x += offsetVector._x; + _y += offsetVector._y; + } + + /// + /// Offset - translate the Location by the offset provided + /// If this is Empty, this method is illegal. + /// + public void Offset(double offsetX, double offsetY) + { + if (IsEmpty) + { + throw new global::System.InvalidOperationException(SR.Rect_CannotCallMethod); + } + + _x += offsetX; + _y += offsetY; + } + + /// + /// Offset - return the result of offsetting rect by the offset provided + /// If this is Empty, this method is illegal. + /// + public static Rect Offset(Rect rect, Vector offsetVector) + { + rect.Offset(offsetVector.X, offsetVector.Y); + return rect; + } + + /// + /// Offset - return the result of offsetting rect by the offset provided + /// If this is Empty, this method is illegal. + /// + public static Rect Offset(Rect rect, double offsetX, double offsetY) + { + rect.Offset(offsetX, offsetY); + return rect; + } + + /// + /// Inflate - inflate the bounds by the size provided, in all directions + /// If this is Empty, this method is illegal. + /// + public void Inflate(Size size) + { + Inflate(size._width, size._height); + } + + /// + /// Inflate - inflate the bounds by the size provided, in all directions. + /// If -width is > Width / 2 or -height is > Height / 2, this Rect becomes Empty + /// If this is Empty, this method is illegal. + /// + public void Inflate(double width, double height) + { + if (IsEmpty) + { + throw new global::System.InvalidOperationException(SR.Rect_CannotCallMethod); + } + + _x -= width; + _y -= height; + + // Do two additions rather than multiplication by 2 to avoid spurious overflow + // That is: (A + 2 * B) != ((A + B) + B) if 2*B overflows. + // Note that multiplication by 2 might work in this case because A should start + // positive & be "clamped" to positive after, but consider A = Inf & B = -MAX. + _width += width; + _width += width; + _height += height; + _height += height; + + // We catch the case of inflation by less than -width/2 or -height/2 here. This also + // maintains the invariant that either the Rect is Empty or _width and _height are + // non-negative, even if the user parameters were NaN, though this isn't strictly maintained + // by other methods. + if (!(_width >= 0 && _height >= 0)) + { + this = s_empty; + } + } + + /// + /// Inflate - return the result of inflating rect by the size provided, in all directions + /// If this is Empty, this method is illegal. + /// + public static Rect Inflate(Rect rect, Size size) + { + rect.Inflate(size._width, size._height); + return rect; + } + + /// + /// Inflate - return the result of inflating rect by the size provided, in all directions + /// If this is Empty, this method is illegal. + /// + public static Rect Inflate(Rect rect, double width, double height) + { + rect.Inflate(width, height); + return rect; + } + + /// + /// Scale the rectangle in the X and Y directions + /// + /// The scale in X + /// The scale in Y + public void Scale(double scaleX, double scaleY) + { + if (IsEmpty) + { + return; + } + + _x *= scaleX; + _y *= scaleY; + _width *= scaleX; + _height *= scaleY; + + // If the scale in the X dimension is negative, we need to normalize X and Width + if (scaleX < 0) + { + // Make X the left-most edge again + _x += _width; + + // and make Width positive + _width *= -1; + } + + // Do the same for the Y dimension + if (scaleY < 0) + { + // Make Y the top-most edge again + _y += _height; + + // and make Height positive + _height *= -1; + } + } + + #endregion Public Methods + + #region Private Methods + + /// + /// ContainsInternal - Performs just the "point inside" logic + /// + /// + /// bool - true if the point is inside the rect + /// + /// The x-coord of the point to test + /// The y-coord of the point to test + private bool ContainsInternal(double x, double y) + { + // We include points on the edge as "contained". + // We do "x - _width <= _x" instead of "x <= _x + _width" + // so that this check works when _width is PositiveInfinity + // and _x is NegativeInfinity. + return ((x >= _x) && (x - _width <= _x) && + (y >= _y) && (y - _height <= _y)); + } + + private static Rect CreateEmptyRect() + { + Rect rect = new Rect + { + // We can't set these via the property setters because negatives widths + // are rejected in those APIs. + _x = Double.PositiveInfinity, + _y = Double.PositiveInfinity, + _width = Double.NegativeInfinity, + _height = Double.NegativeInfinity + }; + return rect; + } + + #endregion Private Methods + + #region Private Fields + + private static readonly Rect s_empty = CreateEmptyRect(); + + #endregion Private Fields + + //------------------------------------------------------ + // + // Public Methods + // + //------------------------------------------------------ + + #region Public Methods + + + + + /// + /// Compares two Rect instances for exact equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which are logically equal may fail. + /// Furthermore, using this equality operator, Double.NaN is not equal to itself. + /// + /// + /// bool - true if the two Rect instances are exactly equal, false otherwise + /// + /// The first Rect to compare + /// The second Rect to compare + public static bool operator ==(Rect rect1, Rect rect2) + { + return rect1.X == rect2.X && + rect1.Y == rect2.Y && + rect1.Width == rect2.Width && + rect1.Height == rect2.Height; + } + + /// + /// Compares two Rect instances for exact inequality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which are logically equal may fail. + /// Furthermore, using this equality operator, Double.NaN is not equal to itself. + /// + /// + /// bool - true if the two Rect instances are exactly unequal, false otherwise + /// + /// The first Rect to compare + /// The second Rect to compare + public static bool operator !=(Rect rect1, Rect rect2) + { + return !(rect1 == rect2); + } + /// + /// Compares two Rect instances for object equality. In this equality + /// Double.NaN is equal to itself, unlike in numeric equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which + /// are logically equal may fail. + /// + /// + /// bool - true if the two Rect instances are exactly equal, false otherwise + /// + /// The first Rect to compare + /// The second Rect to compare + public static bool Equals(Rect rect1, Rect rect2) + { + if (rect1.IsEmpty) + { + return rect2.IsEmpty; + } + else + { + return rect1.X.Equals(rect2.X) && + rect1.Y.Equals(rect2.Y) && + rect1.Width.Equals(rect2.Width) && + rect1.Height.Equals(rect2.Height); + } + } + + /// + /// Equals - compares this Rect with the passed in object. In this equality + /// Double.NaN is equal to itself, unlike in numeric equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which + /// are logically equal may fail. + /// + /// + /// bool - true if the object is an instance of Rect and if it's equal to "this". + /// + /// The object to compare to "this" + public override bool Equals(object o) + { + if ((null == o) || !(o is Rect)) + { + return false; + } + + Rect value = (Rect) o; + return Rect.Equals(this, value); + } + + /// + /// Equals - compares this Rect with the passed in object. In this equality + /// Double.NaN is equal to itself, unlike in numeric equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which + /// are logically equal may fail. + /// + /// + /// bool - true if "value" is equal to "this". + /// + /// The Rect to compare to "this" + public bool Equals(Rect value) + { + return Rect.Equals(this, value); + } + /// + /// Returns the HashCode for this Rect + /// + /// + /// int - the HashCode for this Rect + /// + public override int GetHashCode() + { + if (IsEmpty) + { + return 0; + } + else + { + // Perform field-by-field XOR of HashCodes + return X.GetHashCode() ^ + Y.GetHashCode() ^ + Width.GetHashCode() ^ + Height.GetHashCode(); + } + } + + /// + /// Parse - returns an instance converted from the provided string using + /// the culture "en-US" + /// string with Rect data + /// + public static Rect Parse(string source) + { + throw new NotImplementedException(); + //IFormatProvider formatProvider = System.Windows.Markup.TypeConverterHelper.InvariantEnglishUS; + + //TokenizerHelper th = new TokenizerHelper(source, formatProvider); + + //Rect value; + + //String firstToken = th.NextTokenRequired(); + + //// The token will already have had whitespace trimmed so we can do a + //// simple string compare. + //if (firstToken == "Empty") + //{ + // value = Empty; + //} + //else + //{ + // value = new Rect( + // Convert.ToDouble(firstToken, formatProvider), + // Convert.ToDouble(th.NextTokenRequired(), formatProvider), + // Convert.ToDouble(th.NextTokenRequired(), formatProvider), + // Convert.ToDouble(th.NextTokenRequired(), formatProvider)); + //} + + //// There should be no more tokens in this string. + //th.LastTokenRequired(); + + //return value; + } + + #endregion Public Methods + + //------------------------------------------------------ + // + // Public Properties + // + //------------------------------------------------------ + + + + + #region Public Properties + + + + #endregion Public Properties + + //------------------------------------------------------ + // + // Protected Methods + // + //------------------------------------------------------ + + #region Protected Methods + + + + + + #endregion ProtectedMethods + + //------------------------------------------------------ + // + // Internal Methods + // + //------------------------------------------------------ + + #region Internal Methods + + + + + + + + + + #endregion Internal Methods + + //------------------------------------------------------ + // + // Internal Properties + // + //------------------------------------------------------ + + #region Internal Properties + + + /// + /// Creates a string representation of this object based on the current culture. + /// + /// + /// A string representation of this object. + /// + public override string ToString() + { + + // Delegate to the internal method which implements all ToString calls. + return ConvertToString(null /* format string */, null /* format provider */); + } + + /// + /// Creates a string representation of this object based on the IFormatProvider + /// passed in. If the provider is null, the CurrentCulture is used. + /// + /// + /// A string representation of this object. + /// + public string ToString(IFormatProvider provider) + { + + // Delegate to the internal method which implements all ToString calls. + return ConvertToString(null /* format string */, provider); + } + + ///// + ///// Creates a string representation of this object based on the format string + ///// and IFormatProvider passed in. + ///// If the provider is null, the CurrentCulture is used. + ///// See the documentation for IFormattable for more information. + ///// + ///// + ///// A string representation of this object. + ///// + //string IFormattable.ToString(string format, IFormatProvider provider) + //{ + + // // Delegate to the internal method which implements all ToString calls. + // return ConvertToString(format, provider); + //} + + /// + /// Creates a string representation of this object based on the format string + /// and IFormatProvider passed in. + /// If the provider is null, the CurrentCulture is used. + /// See the documentation for IFormattable for more information. + /// + /// + /// A string representation of this object. + /// + internal string ConvertToString(string format, IFormatProvider provider) + { + //if (IsEmpty) + //{ + // return "Empty"; + //} + + //// Helper to get the numeric list separator for a given culture. + //char separator = MS.Internal.TokenizerHelper.GetNumericListSeparator(provider); + //return String.Format(provider, + // "{1:" + format + "}{0}{2:" + format + "}{0}{3:" + format + "}{0}{4:" + format + "}", + // separator, + // _x, + // _y, + // _width, + // _height); + throw new NotImplementedException(); + } + + + + #endregion Internal Properties + + //------------------------------------------------------ + // + // Dependency Properties + // + //------------------------------------------------------ + + #region Dependency Properties + + + + #endregion Dependency Properties + + //------------------------------------------------------ + // + // Internal Fields + // + //------------------------------------------------------ + + #region Internal Fields + + + internal double _x; + internal double _y; + internal double _width; + internal double _height; + + + + + #endregion Internal Fields + + + + #region Constructors + + //------------------------------------------------------ + // + // Constructors + // + //------------------------------------------------------ + + + + + #endregion Constructors + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Size.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Size.cs new file mode 100644 index 0000000..626e8ae --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Size.cs @@ -0,0 +1,497 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace WpfInk.PresentationCore.System.Windows +{ + internal struct Size : IFormattable + { + #region Constructors + + /// + /// Constructor which sets the size's initial values. Width and Height must be non-negative + /// + /// double - The initial Width + /// double - THe initial Height + public Size(double width, double height) + { + if (width < 0 || height < 0) + { + throw new global::System.ArgumentException(SR.Size_WidthAndHeightCannotBeNegative); + } + + _width = width; + _height = height; + } + + #endregion Constructors + + #region Statics + + /// + /// Empty - a static property which provides an Empty size. Width and Height are + /// negative-infinity. This is the only situation + /// where size can be negative. + /// + public static Size Empty + { + get + { + return s_empty; + } + } + + #endregion Statics + + #region Public Methods and Properties + + /// + /// IsEmpty - this returns true if this size is the Empty size. + /// Note: If size is 0 this Size still contains a 0 or 1 dimensional set + /// of points, so this method should not be used to check for 0 area. + /// + public bool IsEmpty + { + get + { + return _width < 0; + } + } + + /// + /// Width - Default is 0, must be non-negative + /// + public double Width + { + get + { + return _width; + } + set + { + if (IsEmpty) + { + throw new global::System.InvalidOperationException(SR.Size_CannotModifyEmptySize); + } + + if (value < 0) + { + throw new global::System.ArgumentException(SR.Size_WidthCannotBeNegative); + } + + _width = value; + } + } + + /// + /// Height - Default is 0, must be non-negative. + /// + public double Height + { + get + { + return _height; + } + set + { + if (IsEmpty) + { + throw new global::System.InvalidOperationException(SR.Size_CannotModifyEmptySize); + } + + if (value < 0) + { + throw new global::System.ArgumentException(SR.Size_HeightCannotBeNegative); + } + + _height = value; + } + } + + #endregion Public Methods + + #region Public Operators + + /// + /// Explicit conversion to Vector. + /// + /// + /// Vector - A Vector equal to this Size + /// + /// Size - the Size to convert to a Vector + public static explicit operator Vector(Size size) + { + return new Vector(size._width, size._height); + } + + /// + /// Explicit conversion to Point + /// + /// + /// Point - A Point equal to this Size + /// + /// Size - the Size to convert to a Point + public static explicit operator Point(Size size) + { + return new Point(size._width, size._height); + } + + #endregion Public Operators + + #region Private Methods + + private static Size CreateEmptySize() + { + Size size = new Size + { + // We can't set these via the property setters because negatives widths + // are rejected in those APIs. + _width = Double.NegativeInfinity, + _height = Double.NegativeInfinity + }; + return size; + } + + #endregion Private Methods + + #region Private Fields + + private static readonly Size s_empty = CreateEmptySize(); + + #endregion Private Fields + + //------------------------------------------------------ + // + // Public Methods + // + //------------------------------------------------------ + + #region Public Methods + + + + + /// + /// Compares two Size instances for exact equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which are logically equal may fail. + /// Furthermore, using this equality operator, Double.NaN is not equal to itself. + /// + /// + /// bool - true if the two Size instances are exactly equal, false otherwise + /// + /// The first Size to compare + /// The second Size to compare + public static bool operator ==(Size size1, Size size2) + { + return size1.Width == size2.Width && + size1.Height == size2.Height; + } + + /// + /// Compares two Size instances for exact inequality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which are logically equal may fail. + /// Furthermore, using this equality operator, Double.NaN is not equal to itself. + /// + /// + /// bool - true if the two Size instances are exactly unequal, false otherwise + /// + /// The first Size to compare + /// The second Size to compare + public static bool operator !=(Size size1, Size size2) + { + return !(size1 == size2); + } + /// + /// Compares two Size instances for object equality. In this equality + /// Double.NaN is equal to itself, unlike in numeric equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which + /// are logically equal may fail. + /// + /// + /// bool - true if the two Size instances are exactly equal, false otherwise + /// + /// The first Size to compare + /// The second Size to compare + public static bool Equals(Size size1, Size size2) + { + if (size1.IsEmpty) + { + return size2.IsEmpty; + } + else + { + return size1.Width.Equals(size2.Width) && + size1.Height.Equals(size2.Height); + } + } + + /// + /// Equals - compares this Size with the passed in object. In this equality + /// Double.NaN is equal to itself, unlike in numeric equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which + /// are logically equal may fail. + /// + /// + /// bool - true if the object is an instance of Size and if it's equal to "this". + /// + /// The object to compare to "this" + public override bool Equals(object o) + { + if ((null == o) || !(o is Size)) + { + return false; + } + + Size value = (Size) o; + return Size.Equals(this, value); + } + + /// + /// Equals - compares this Size with the passed in object. In this equality + /// Double.NaN is equal to itself, unlike in numeric equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which + /// are logically equal may fail. + /// + /// + /// bool - true if "value" is equal to "this". + /// + /// The Size to compare to "this" + public bool Equals(Size value) + { + return Size.Equals(this, value); + } + /// + /// Returns the HashCode for this Size + /// + /// + /// int - the HashCode for this Size + /// + public override int GetHashCode() + { + if (IsEmpty) + { + return 0; + } + else + { + // Perform field-by-field XOR of HashCodes + return Width.GetHashCode() ^ + Height.GetHashCode(); + } + } + + /// + /// Parse - returns an instance converted from the provided string using + /// the culture "en-US" + /// string with Size data + /// + public static Size Parse(string source) + { + throw new NotImplementedException(); + //IFormatProvider formatProvider = System.Windows.Markup.TypeConverterHelper.InvariantEnglishUS; + + //TokenizerHelper th = new TokenizerHelper(source, formatProvider); + + //Size value; + + //String firstToken = th.NextTokenRequired(); + + //// The token will already have had whitespace trimmed so we can do a + //// simple string compare. + //if (firstToken == "Empty") + //{ + // value = Empty; + //} + //else + //{ + // value = new Size( + // Convert.ToDouble(firstToken, formatProvider), + // Convert.ToDouble(th.NextTokenRequired(), formatProvider)); + //} + + //// There should be no more tokens in this string. + //th.LastTokenRequired(); + + //return value; + } + + #endregion Public Methods + + //------------------------------------------------------ + // + // Public Properties + // + //------------------------------------------------------ + + + + + #region Public Properties + + + + #endregion Public Properties + + //------------------------------------------------------ + // + // Protected Methods + // + //------------------------------------------------------ + + #region Protected Methods + + + + + + #endregion ProtectedMethods + + //------------------------------------------------------ + // + // Internal Methods + // + //------------------------------------------------------ + + #region Internal Methods + + + + + + + + + + #endregion Internal Methods + + //------------------------------------------------------ + // + // Internal Properties + // + //------------------------------------------------------ + + #region Internal Properties + + + /// + /// Creates a string representation of this object based on the current culture. + /// + /// + /// A string representation of this object. + /// + public override string ToString() + { + + // Delegate to the internal method which implements all ToString calls. + return ConvertToString(null /* format string */, null /* format provider */); + } + + /// + /// Creates a string representation of this object based on the IFormatProvider + /// passed in. If the provider is null, the CurrentCulture is used. + /// + /// + /// A string representation of this object. + /// + public string ToString(IFormatProvider provider) + { + + // Delegate to the internal method which implements all ToString calls. + return ConvertToString(null /* format string */, provider); + } + + /// + /// Creates a string representation of this object based on the format string + /// and IFormatProvider passed in. + /// If the provider is null, the CurrentCulture is used. + /// See the documentation for IFormattable for more information. + /// + /// + /// A string representation of this object. + /// + string IFormattable.ToString(string format, IFormatProvider provider) + { + + // Delegate to the internal method which implements all ToString calls. + return ConvertToString(format, provider); + } + + /// + /// Creates a string representation of this object based on the format string + /// and IFormatProvider passed in. + /// If the provider is null, the CurrentCulture is used. + /// See the documentation for IFormattable for more information. + /// + /// + /// A string representation of this object. + /// + internal string ConvertToString(string format, IFormatProvider provider) + { + throw new NotImplementedException(); + //if (IsEmpty) + //{ + // return "Empty"; + //} + + //// Helper to get the numeric list separator for a given culture. + //char separator = MS.Internal.TokenizerHelper.GetNumericListSeparator(provider); + //return String.Format(provider, + // "{1:" + format + "}{0}{2:" + format + "}", + // separator, + // _width, + // _height); + } + + #endregion Internal Properties + + //------------------------------------------------------ + // + // Dependency Properties + // + //------------------------------------------------------ + + #region Dependency Properties + + + + #endregion Dependency Properties + + //------------------------------------------------------ + // + // Internal Fields + // + //------------------------------------------------------ + + #region Internal Fields + + + internal double _width; + internal double _height; + + + + + #endregion Internal Fields + + + + #region Constructors + + //------------------------------------------------------ + // + // Constructors + // + //------------------------------------------------------ + + + + + #endregion Constructors + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Vector.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Vector.cs new file mode 100644 index 0000000..5cd8ab7 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/PresentationCore/System/Windows/Vector.cs @@ -0,0 +1,557 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace WpfInk.PresentationCore.System.Windows +{ + internal struct Vector + { + #region Constructors + + /// + /// Constructor which sets the vector's initial values + /// + /// double - The initial X + /// double - THe initial Y + public Vector(double x, double y) + { + _x = x; + _y = y; + } + + #endregion Constructors + + #region Public Methods + + /// + /// Length Property - the length of this Vector + /// + public double Length + { + get + { + return Math.Sqrt(_x * _x + _y * _y); + } + } + + /// + /// LengthSquared Property - the squared length of this Vector + /// + public double LengthSquared + { + get + { + return _x * _x + _y * _y; + } + } + + /// + /// Normalize - Updates this Vector to maintain its direction, but to have a length + /// of 1. This is equivalent to dividing this Vector by Length + /// + public void Normalize() + { + // Avoid overflow + this /= Math.Max(Math.Abs(_x), Math.Abs(_y)); + this /= Length; + } + + /// + /// CrossProduct - Returns the cross product: vector1.X*vector2.Y - vector1.Y*vector2.X + /// + /// + /// Returns the cross product: vector1.X*vector2.Y - vector1.Y*vector2.X + /// + /// The first Vector + /// The second Vector + public static double CrossProduct(Vector vector1, Vector vector2) + { + return vector1._x * vector2._y - vector1._y * vector2._x; + } + + /// + /// AngleBetween - the angle between 2 vectors + /// + /// + /// Returns the the angle in degrees between vector1 and vector2 + /// + /// The first Vector + /// The second Vector + public static double AngleBetween(Vector vector1, Vector vector2) + { + double sin = vector1._x * vector2._y - vector2._x * vector1._y; + double cos = vector1._x * vector2._x + vector1._y * vector2._y; + + return Math.Atan2(sin, cos) * (180 / Math.PI); + } + + #endregion Public Methods + + #region Public Operators + /// + /// Operator -Vector (unary negation) + /// + public static Vector operator -(Vector vector) + { + return new Vector(-vector._x, -vector._y); + } + + /// + /// Negates the values of X and Y on this Vector + /// + public void Negate() + { + _x = -_x; + _y = -_y; + } + + /// + /// Operator Vector + Vector + /// + public static Vector operator +(Vector vector1, Vector vector2) + { + return new Vector(vector1._x + vector2._x, + vector1._y + vector2._y); + } + + /// + /// Add: Vector + Vector + /// + public static Vector Add(Vector vector1, Vector vector2) + { + return new Vector(vector1._x + vector2._x, + vector1._y + vector2._y); + } + + /// + /// Operator Vector - Vector + /// + public static Vector operator -(Vector vector1, Vector vector2) + { + return new Vector(vector1._x - vector2._x, + vector1._y - vector2._y); + } + + /// + /// Subtract: Vector - Vector + /// + public static Vector Subtract(Vector vector1, Vector vector2) + { + return new Vector(vector1._x - vector2._x, + vector1._y - vector2._y); + } + + /// + /// Operator Vector + Point + /// + public static Point operator +(Vector vector, Point point) + { + return new Point(point._x + vector._x, point._y + vector._y); + } + + /// + /// Add: Vector + Point + /// + public static Point Add(Vector vector, Point point) + { + return new Point(point._x + vector._x, point._y + vector._y); + } + + /// + /// Operator Vector * double + /// + public static Vector operator *(Vector vector, double scalar) + { + return new Vector(vector._x * scalar, + vector._y * scalar); + } + + /// + /// Multiply: Vector * double + /// + public static Vector Multiply(Vector vector, double scalar) + { + return new Vector(vector._x * scalar, + vector._y * scalar); + } + + /// + /// Operator double * Vector + /// + public static Vector operator *(double scalar, Vector vector) + { + return new Vector(vector._x * scalar, + vector._y * scalar); + } + + /// + /// Multiply: double * Vector + /// + public static Vector Multiply(double scalar, Vector vector) + { + return new Vector(vector._x * scalar, + vector._y * scalar); + } + + /// + /// Operator Vector / double + /// + public static Vector operator /(Vector vector, double scalar) + { + return vector * (1.0 / scalar); + } + + /// + /// Multiply: Vector / double + /// + public static Vector Divide(Vector vector, double scalar) + { + return vector * (1.0 / scalar); + } + + /// + /// Operator Vector * Vector, interpreted as their dot product + /// + public static double operator *(Vector vector1, Vector vector2) + { + return vector1._x * vector2._x + vector1._y * vector2._y; + } + + /// + /// Multiply - Returns the dot product: vector1.X*vector2.X + vector1.Y*vector2.Y + /// + /// + /// Returns the dot product: vector1.X*vector2.X + vector1.Y*vector2.Y + /// + /// The first Vector + /// The second Vector + public static double Multiply(Vector vector1, Vector vector2) + { + return vector1._x * vector2._x + vector1._y * vector2._y; + } + + /// + /// Determinant - Returns the determinant det(vector1, vector2) + /// + /// + /// Returns the determinant: vector1.X*vector2.Y - vector1.Y*vector2.X + /// + /// The first Vector + /// The second Vector + public static double Determinant(Vector vector1, Vector vector2) + { + return vector1._x * vector2._y - vector1._y * vector2._x; + } + + /// + /// Explicit conversion to Size. Note that since Size cannot contain negative values, + /// the resulting size will contains the absolute values of X and Y + /// + /// + /// Size - A Size equal to this Vector + /// + /// Vector - the Vector to convert to a Size + public static explicit operator Size(Vector vector) + { + return new Size(Math.Abs(vector._x), Math.Abs(vector._y)); + } + + /// + /// Explicit conversion to Point + /// + /// + /// Point - A Point equal to this Vector + /// + /// Vector - the Vector to convert to a Point + public static explicit operator Point(Vector vector) + { + return new Point(vector._x, vector._y); + } + #endregion Public Operators + + //------------------------------------------------------ + // + // Public Methods + // + //------------------------------------------------------ + + #region Public Methods + + + + + /// + /// Compares two Vector instances for exact equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which are logically equal may fail. + /// Furthermore, using this equality operator, Double.NaN is not equal to itself. + /// + /// + /// bool - true if the two Vector instances are exactly equal, false otherwise + /// + /// The first Vector to compare + /// The second Vector to compare + public static bool operator ==(Vector vector1, Vector vector2) + { + return vector1.X == vector2.X && + vector1.Y == vector2.Y; + } + + /// + /// Compares two Vector instances for exact inequality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which are logically equal may fail. + /// Furthermore, using this equality operator, Double.NaN is not equal to itself. + /// + /// + /// bool - true if the two Vector instances are exactly unequal, false otherwise + /// + /// The first Vector to compare + /// The second Vector to compare + public static bool operator !=(Vector vector1, Vector vector2) + { + return !(vector1 == vector2); + } + /// + /// Compares two Vector instances for object equality. In this equality + /// Double.NaN is equal to itself, unlike in numeric equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which + /// are logically equal may fail. + /// + /// + /// bool - true if the two Vector instances are exactly equal, false otherwise + /// + /// The first Vector to compare + /// The second Vector to compare + public static bool Equals(Vector vector1, Vector vector2) + { + return vector1.X.Equals(vector2.X) && + vector1.Y.Equals(vector2.Y); + } + + /// + /// Equals - compares this Vector with the passed in object. In this equality + /// Double.NaN is equal to itself, unlike in numeric equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which + /// are logically equal may fail. + /// + /// + /// bool - true if the object is an instance of Vector and if it's equal to "this". + /// + /// The object to compare to "this" + public override bool Equals(object o) + { + if ((null == o) || !(o is Vector)) + { + return false; + } + + Vector value = (Vector) o; + return Vector.Equals(this, value); + } + + /// + /// Equals - compares this Vector with the passed in object. In this equality + /// Double.NaN is equal to itself, unlike in numeric equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which + /// are logically equal may fail. + /// + /// + /// bool - true if "value" is equal to "this". + /// + /// The Vector to compare to "this" + public bool Equals(Vector value) + { + return Vector.Equals(this, value); + } + /// + /// Returns the HashCode for this Vector + /// + /// + /// int - the HashCode for this Vector + /// + public override int GetHashCode() + { + // Perform field-by-field XOR of HashCodes + return X.GetHashCode() ^ + Y.GetHashCode(); + } + + /// + /// Parse - returns an instance converted from the provided string using + /// the culture "en-US" + /// string with Vector data + /// + public static Vector Parse(string source) + { + throw new NotImplementedException(); + //IFormatProvider formatProvider = System.Windows.Markup.TypeConverterHelper.InvariantEnglishUS; + + //TokenizerHelper th = new TokenizerHelper(source, formatProvider); + + //Vector value; + + //String firstToken = th.NextTokenRequired(); + + //value = new Vector( + // Convert.ToDouble(firstToken, formatProvider), + // Convert.ToDouble(th.NextTokenRequired(), formatProvider)); + + //// There should be no more tokens in this string. + //th.LastTokenRequired(); + + //return value; + } + + #endregion Public Methods + + //------------------------------------------------------ + // + // Public Properties + // + //------------------------------------------------------ + + + + + #region Public Properties + + /// + /// X - double. Default value is 0. + /// + public double X + { + get + { + return _x; + } + + set + { + _x = value; + } + + } + + /// + /// Y - double. Default value is 0. + /// + public double Y + { + get + { + return _y; + } + + set + { + _y = value; + } + + } + + #endregion Public Properties + + //------------------------------------------------------ + // + // Protected Methods + // + //------------------------------------------------------ + + #region Protected Methods + + + + + + #endregion ProtectedMethods + + //------------------------------------------------------ + // + // Internal Methods + // + //------------------------------------------------------ + + #region Internal Methods + + + + + + + + + + #endregion Internal Methods + + //------------------------------------------------------ + // + // Internal Properties + // + //------------------------------------------------------ + + #region Internal Properties + + + /// + /// Creates a string representation of this object based on the current culture. + /// + /// + /// A string representation of this object. + /// + public override string ToString() + { + return $"({_x},{_y})"; + } + + #endregion Internal Properties + + //------------------------------------------------------ + // + // Dependency Properties + // + //------------------------------------------------------ + + #region Dependency Properties + + + + #endregion Dependency Properties + + //------------------------------------------------------ + // + // Internal Fields + // + //------------------------------------------------------ + + #region Internal Fields + + + internal double _x; + internal double _y; + + + + + #endregion Internal Fields + + + + #region Constructors + + //------------------------------------------------------ + // + // Constructors + // + //------------------------------------------------------ + + + + + #endregion Constructors + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/WindowsBase/Matrix.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/WindowsBase/Matrix.cs new file mode 100644 index 0000000..73d9560 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/WindowsBase/Matrix.cs @@ -0,0 +1,1029 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// + +using System; +using System.Diagnostics; +using System.ComponentModel; +using System.ComponentModel.Design.Serialization; +using System.Reflection; +using MS.Internal; +using MS.Internal.WindowsBase; +using System.Text; +using System.Collections; +using System.Globalization; +using System.Windows; +using System.Runtime.InteropServices; +using System.Security; +using WpfInk.PresentationCore.System.Windows; + +// IMPORTANT +// +// Rules for using matrix types. +// +// internal enum MatrixTypes +// { +// TRANSFORM_IS_IDENTITY = 0, +// TRANSFORM_IS_TRANSLATION = 1, +// TRANSFORM_IS_SCALING = 2, +// TRANSFORM_IS_UNKNOWN = 4 +// } +// +// 1. Matrix type must be one of 0, 1, 2, 4, or 3 (for scale and translation) +// 2. Matrix types are true but not exact! (E.G. A scale or identity transform could be marked as unknown or scale+translate.) +// 3. Therefore read-only operations can ignore the type with one exception +// EXCEPTION: A matrix tagged identity might have any coefficients instead of 1,0,0,1,0,0 +// This is the (now) classic no default constructor for structs issue +// 4. Matrix._type must be maintained by mutation operations +// 5. MS.Internal.MatrixUtil uses unsafe code to access the private members of Matrix including _type. +// +// In Jan 2005 the matrix types were changed from being EXACT (i.e. a +// scale matrix is always tagged as a scale and not something more +// general.) This resulted in about a 2% speed up in matrix +// multiplication. +// +// The special cases for matrix multiplication speed up scale*scale +// and translation*translation by 30% compared to a single "no-branch" +// multiplication algorithm. Matrix multiplication of two unknown +// matrices is slowed by 20% compared to the no-branch algorithm. +// +// windows/wcp/DevTest/Drts/MediaApi/MediaPerf.cs includes the +// simple test of matrix multiplication speed used for these results. + +namespace WpfInk.WindowsBase.System.Windows.Media +{ + /// + /// Matrix + /// + internal partial struct Matrix : IFormattable + { + // the transform is identity by default + // Actually fill in the fields - some (internal) code uses the fields directly for perf. + private static Matrix s_identity = CreateIdentity(); + + #region Constructor + + /// + /// Creates a matrix of the form + /// / m11, m12, 0 \ + /// | m21, m22, 0 | + /// \ offsetX, offsetY, 1 / + /// + public Matrix(double m11, double m12, + double m21, double m22, + double offsetX, double offsetY) + { + this._m11 = m11; + this._m12 = m12; + this._m21 = m21; + this._m22 = m22; + this._offsetX = offsetX; + this._offsetY = offsetY; + _type = MatrixTypes.TRANSFORM_IS_UNKNOWN; + _padding = 0; + + // We will detect EXACT identity, scale, translation or + // scale+translation and use special case algorithms. + DeriveMatrixType(); + } + + #endregion Constructor + + #region Identity + + /// + /// Identity + /// + public static Matrix Identity + { + get + { + return s_identity; + } + } + + /// + /// Sets the matrix to identity. + /// + public void SetIdentity() + { + _type = MatrixTypes.TRANSFORM_IS_IDENTITY; + } + + /// + /// Tests whether or not a given transform is an identity transform + /// + public bool IsIdentity + { + get + { + return (_type == MatrixTypes.TRANSFORM_IS_IDENTITY || + (_m11 == 1 && _m12 == 0 && _m21 == 0 && _m22 == 1 && _offsetX == 0 && _offsetY == 0)); + } + } + + #endregion Identity + + #region Operators + /// + /// Multiplies two transformations. + /// + public static Matrix operator *(Matrix trans1, Matrix trans2) + { + MatrixUtil.MultiplyMatrix(ref trans1, ref trans2); + trans1.Debug_CheckType(); + return trans1; + } + + /// + /// Multiply + /// + public static Matrix Multiply(Matrix trans1, Matrix trans2) + { + MatrixUtil.MultiplyMatrix(ref trans1, ref trans2); + trans1.Debug_CheckType(); + return trans1; + } + + #endregion Operators + + #region Combine Methods + + /// + /// Append - "this" becomes this * matrix, the same as this *= matrix. + /// + /// The Matrix to append to this Matrix + public void Append(Matrix matrix) + { + this *= matrix; + } + + /// + /// Prepend - "this" becomes matrix * this, the same as this = matrix * this. + /// + /// The Matrix to prepend to this Matrix + public void Prepend(Matrix matrix) + { + this = matrix * this; + } + + /// + /// Rotates this matrix about the origin + /// + /// The angle to rotate specified in degrees + public void Rotate(double angle) + { + angle %= 360.0; // Doing the modulo before converting to radians reduces total error + this *= CreateRotationRadians(angle * (Math.PI / 180.0)); + } + + /// + /// Prepends a rotation about the origin to "this" + /// + /// The angle to rotate specified in degrees + public void RotatePrepend(double angle) + { + angle %= 360.0; // Doing the modulo before converting to radians reduces total error + this = CreateRotationRadians(angle * (Math.PI / 180.0)) * this; + } + + /// + /// Rotates this matrix about the given point + /// + /// The angle to rotate specified in degrees + /// The centerX of rotation + /// The centerY of rotation + public void RotateAt(double angle, double centerX, double centerY) + { + angle %= 360.0; // Doing the modulo before converting to radians reduces total error + this *= CreateRotationRadians(angle * (Math.PI / 180.0), centerX, centerY); + } + + /// + /// Prepends a rotation about the given point to "this" + /// + /// The angle to rotate specified in degrees + /// The centerX of rotation + /// The centerY of rotation + public void RotateAtPrepend(double angle, double centerX, double centerY) + { + angle %= 360.0; // Doing the modulo before converting to radians reduces total error + this = CreateRotationRadians(angle * (Math.PI / 180.0), centerX, centerY) * this; + } + + /// + /// Scales this matrix around the origin + /// + /// The scale factor in the x dimension + /// The scale factor in the y dimension + public void Scale(double scaleX, double scaleY) + { + this *= CreateScaling(scaleX, scaleY); + } + + /// + /// Prepends a scale around the origin to "this" + /// + /// The scale factor in the x dimension + /// The scale factor in the y dimension + public void ScalePrepend(double scaleX, double scaleY) + { + this = CreateScaling(scaleX, scaleY) * this; + } + + /// + /// Scales this matrix around the center provided + /// + /// The scale factor in the x dimension + /// The scale factor in the y dimension + /// The centerX about which to scale + /// The centerY about which to scale + public void ScaleAt(double scaleX, double scaleY, double centerX, double centerY) + { + this *= CreateScaling(scaleX, scaleY, centerX, centerY); + } + + /// + /// Prepends a scale around the center provided to "this" + /// + /// The scale factor in the x dimension + /// The scale factor in the y dimension + /// The centerX about which to scale + /// The centerY about which to scale + public void ScaleAtPrepend(double scaleX, double scaleY, double centerX, double centerY) + { + this = CreateScaling(scaleX, scaleY, centerX, centerY) * this; + } + + /// + /// Skews this matrix + /// + /// The skew angle in the x dimension in degrees + /// The skew angle in the y dimension in degrees + public void Skew(double skewX, double skewY) + { + skewX %= 360; + skewY %= 360; + this *= CreateSkewRadians(skewX * (Math.PI / 180.0), + skewY * (Math.PI / 180.0)); + } + + /// + /// Prepends a skew to this matrix + /// + /// The skew angle in the x dimension in degrees + /// The skew angle in the y dimension in degrees + public void SkewPrepend(double skewX, double skewY) + { + skewX %= 360; + skewY %= 360; + this = CreateSkewRadians(skewX * (Math.PI / 180.0), + skewY * (Math.PI / 180.0)) * this; + } + + /// + /// Translates this matrix + /// + /// The offset in the x dimension + /// The offset in the y dimension + public void Translate(double offsetX, double offsetY) + { + // + // / a b 0 \ / 1 0 0 \ / a b 0 \ + // | c d 0 | * | 0 1 0 | = | c d 0 | + // \ e f 1 / \ x y 1 / \ e+x f+y 1 / + // + // (where e = _offsetX and f == _offsetY) + // + + if (_type == MatrixTypes.TRANSFORM_IS_IDENTITY) + { + // Values would be incorrect if matrix was created using default constructor. + // or if SetIdentity was called on a matrix which had values. + // + SetMatrix(1, 0, + 0, 1, + offsetX, offsetY, + MatrixTypes.TRANSFORM_IS_TRANSLATION); + } + else if (_type == MatrixTypes.TRANSFORM_IS_UNKNOWN) + { + _offsetX += offsetX; + _offsetY += offsetY; + } + else + { + _offsetX += offsetX; + _offsetY += offsetY; + + // If matrix wasn't unknown we added a translation + _type |= MatrixTypes.TRANSFORM_IS_TRANSLATION; + } + + Debug_CheckType(); + } + + /// + /// Prepends a translation to this matrix + /// + /// The offset in the x dimension + /// The offset in the y dimension + public void TranslatePrepend(double offsetX, double offsetY) + { + this = CreateTranslation(offsetX, offsetY) * this; + } + + #endregion Set Methods + + #region Transformation Services + + /// + /// Transform - returns the result of transforming the point by this matrix + /// + /// + /// The transformed point + /// + /// The Point to transform + public Point Transform(Point point) + { + Point newPoint = point; + var x = newPoint.X; + var y = newPoint.Y; + MultiplyPoint(ref x, ref y); + return new Point(x, y); + } + + /// + /// Transform - Transforms each point in the array by this matrix + /// + /// The Point array to transform + public void Transform(Point[] points) + { + if (points != null) + { + for (int i = 0; i < points.Length; i++) + { + var point = points[i]; + var x = point.X; + var y = point.Y; + MultiplyPoint(ref x, ref y); + points[i] = new Point(x, y); + } + } + } + + /// + /// Transform - returns the result of transforming the Vector by this matrix. + /// + /// + /// The transformed vector + /// + /// The Vector to transform + public Vector Transform(Vector vector) + { + var x = vector.X; + var y = vector.Y; + MultiplyVector(ref x, ref y); + Vector newVector = new Vector(x, y); + return newVector; + } + + /// + /// Transform - Transforms each Vector in the array by this matrix. + /// + /// The Vector array to transform + public void Transform(Vector[] vectors) + { + if (vectors != null) + { + for (int i = 0; i < vectors.Length; i++) + { + var vector = vectors[i]; + var x = vector.X; + var y = vector.Y; + MultiplyVector(ref x, ref y); + Vector newVector = new Vector(x, y); + vectors[i] = newVector; + } + } + } + + #endregion Transformation Services + + #region Inversion + + /// + /// The determinant of this matrix + /// + public double Determinant + { + get + { + switch (_type) + { + case MatrixTypes.TRANSFORM_IS_IDENTITY: + case MatrixTypes.TRANSFORM_IS_TRANSLATION: + return 1.0; + case MatrixTypes.TRANSFORM_IS_SCALING: + case MatrixTypes.TRANSFORM_IS_SCALING | MatrixTypes.TRANSFORM_IS_TRANSLATION: + return (_m11 * _m22); + default: + return (_m11 * _m22) - (_m12 * _m21); + } + } + } + + /// + /// HasInverse Property - returns true if this matrix is invertable, false otherwise. + /// + public bool HasInverse + { + get + { + return !DoubleUtil.IsZero(Determinant); + } + } + + /// + /// Replaces matrix with the inverse of the transformation. This will throw an InvalidOperationException + /// if !HasInverse + /// + /// + /// This will throw an InvalidOperationException if the matrix is non-invertable + /// + public void Invert() + { + double determinant = Determinant; + + if (DoubleUtil.IsZero(determinant)) + { + throw new global::System.InvalidOperationException(); + } + + // Inversion does not change the type of a matrix. + switch (_type) + { + case MatrixTypes.TRANSFORM_IS_IDENTITY: + break; + case MatrixTypes.TRANSFORM_IS_SCALING: + { + _m11 = 1.0 / _m11; + _m22 = 1.0 / _m22; + } + break; + case MatrixTypes.TRANSFORM_IS_TRANSLATION: + _offsetX = -_offsetX; + _offsetY = -_offsetY; + break; + case MatrixTypes.TRANSFORM_IS_SCALING | MatrixTypes.TRANSFORM_IS_TRANSLATION: + { + _m11 = 1.0 / _m11; + _m22 = 1.0 / _m22; + _offsetX = -_offsetX * _m11; + _offsetY = -_offsetY * _m22; + } + break; + default: + { + double invdet = 1.0 / determinant; + SetMatrix(_m22 * invdet, + -_m12 * invdet, + -_m21 * invdet, + _m11 * invdet, + (_m21 * _offsetY - _offsetX * _m22) * invdet, + (_offsetX * _m12 - _m11 * _offsetY) * invdet, + MatrixTypes.TRANSFORM_IS_UNKNOWN); + } + break; + } + } + + #endregion Inversion + + #region Public Properties + + /// + /// M11 + /// + public double M11 + { + get + { + if (_type == MatrixTypes.TRANSFORM_IS_IDENTITY) + { + return 1.0; + } + else + { + return _m11; + } + } + set + { + if (_type == MatrixTypes.TRANSFORM_IS_IDENTITY) + { + SetMatrix(value, 0, + 0, 1, + 0, 0, + MatrixTypes.TRANSFORM_IS_SCALING); + } + else + { + _m11 = value; + if (_type != MatrixTypes.TRANSFORM_IS_UNKNOWN) + { + _type |= MatrixTypes.TRANSFORM_IS_SCALING; + } + } + } + } + + /// + /// M12 + /// + public double M12 + { + get + { + if (_type == MatrixTypes.TRANSFORM_IS_IDENTITY) + { + return 0; + } + else + { + return _m12; + } + } + set + { + if (_type == MatrixTypes.TRANSFORM_IS_IDENTITY) + { + SetMatrix(1, value, + 0, 1, + 0, 0, + MatrixTypes.TRANSFORM_IS_UNKNOWN); + } + else + { + _m12 = value; + _type = MatrixTypes.TRANSFORM_IS_UNKNOWN; + } + } + } + + /// + /// M22 + /// + public double M21 + { + get + { + if (_type == MatrixTypes.TRANSFORM_IS_IDENTITY) + { + return 0; + } + else + { + return _m21; + } + } + set + { + if (_type == MatrixTypes.TRANSFORM_IS_IDENTITY) + { + SetMatrix(1, 0, + value, 1, + 0, 0, + MatrixTypes.TRANSFORM_IS_UNKNOWN); + } + else + { + _m21 = value; + _type = MatrixTypes.TRANSFORM_IS_UNKNOWN; + } + } + } + + /// + /// M22 + /// + public double M22 + { + get + { + if (_type == MatrixTypes.TRANSFORM_IS_IDENTITY) + { + return 1.0; + } + else + { + return _m22; + } + } + set + { + if (_type == MatrixTypes.TRANSFORM_IS_IDENTITY) + { + SetMatrix(1, 0, + 0, value, + 0, 0, + MatrixTypes.TRANSFORM_IS_SCALING); + } + else + { + _m22 = value; + if (_type != MatrixTypes.TRANSFORM_IS_UNKNOWN) + { + _type |= MatrixTypes.TRANSFORM_IS_SCALING; + } + } + } + } + + /// + /// OffsetX + /// + public double OffsetX + { + get + { + if (_type == MatrixTypes.TRANSFORM_IS_IDENTITY) + { + return 0; + } + else + { + return _offsetX; + } + } + set + { + if (_type == MatrixTypes.TRANSFORM_IS_IDENTITY) + { + SetMatrix(1, 0, + 0, 1, + value, 0, + MatrixTypes.TRANSFORM_IS_TRANSLATION); + } + else + { + _offsetX = value; + if (_type != MatrixTypes.TRANSFORM_IS_UNKNOWN) + { + _type |= MatrixTypes.TRANSFORM_IS_TRANSLATION; + } + } + } + } + + /// + /// OffsetY + /// + public double OffsetY + { + get + { + if (_type == MatrixTypes.TRANSFORM_IS_IDENTITY) + { + return 0; + } + else + { + return _offsetY; + } + } + set + { + if (_type == MatrixTypes.TRANSFORM_IS_IDENTITY) + { + SetMatrix(1, 0, + 0, 1, + 0, value, + MatrixTypes.TRANSFORM_IS_TRANSLATION); + } + else + { + _offsetY = value; + if (_type != MatrixTypes.TRANSFORM_IS_UNKNOWN) + { + _type |= MatrixTypes.TRANSFORM_IS_TRANSLATION; + } + } + } + } + + #endregion Public Properties + + #region Internal Methods + /// + /// MultiplyVector + /// + internal void MultiplyVector(ref double x, ref double y) + { + switch (_type) + { + case MatrixTypes.TRANSFORM_IS_IDENTITY: + case MatrixTypes.TRANSFORM_IS_TRANSLATION: + return; + case MatrixTypes.TRANSFORM_IS_SCALING: + case MatrixTypes.TRANSFORM_IS_SCALING | MatrixTypes.TRANSFORM_IS_TRANSLATION: + x *= _m11; + y *= _m22; + break; + default: + double xadd = y * _m21; + double yadd = x * _m12; + x *= _m11; + x += xadd; + y *= _m22; + y += yadd; + break; + } + } + + /// + /// MultiplyPoint + /// + internal void MultiplyPoint(ref double x, ref double y) + { + switch (_type) + { + case MatrixTypes.TRANSFORM_IS_IDENTITY: + return; + case MatrixTypes.TRANSFORM_IS_TRANSLATION: + x += _offsetX; + y += _offsetY; + return; + case MatrixTypes.TRANSFORM_IS_SCALING: + x *= _m11; + y *= _m22; + return; + case MatrixTypes.TRANSFORM_IS_SCALING | MatrixTypes.TRANSFORM_IS_TRANSLATION: + x *= _m11; + x += _offsetX; + y *= _m22; + y += _offsetY; + break; + default: + double xadd = y * _m21 + _offsetX; + double yadd = x * _m12 + _offsetY; + x *= _m11; + x += xadd; + y *= _m22; + y += yadd; + break; + } + } + + /// + /// Creates a rotation transformation about the given point + /// + /// The angle to rotate specified in radians + internal static Matrix CreateRotationRadians(double angle) + { + return CreateRotationRadians(angle, /* centerX = */ 0, /* centerY = */ 0); + } + + /// + /// Creates a rotation transformation about the given point + /// + /// The angle to rotate specified in radians + /// The centerX of rotation + /// The centerY of rotation + internal static Matrix CreateRotationRadians(double angle, double centerX, double centerY) + { + Matrix matrix = new Matrix(); + + double sin = Math.Sin(angle); + double cos = Math.Cos(angle); + double dx = (centerX * (1.0 - cos)) + (centerY * sin); + double dy = (centerY * (1.0 - cos)) - (centerX * sin); + + matrix.SetMatrix(cos, sin, + -sin, cos, + dx, dy, + MatrixTypes.TRANSFORM_IS_UNKNOWN); + + return matrix; + } + + /// + /// Creates a scaling transform around the given point + /// + /// The scale factor in the x dimension + /// The scale factor in the y dimension + /// The centerX of scaling + /// The centerY of scaling + internal static Matrix CreateScaling(double scaleX, double scaleY, double centerX, double centerY) + { + Matrix matrix = new Matrix(); + + matrix.SetMatrix(scaleX, 0, + 0, scaleY, + centerX - scaleX * centerX, centerY - scaleY * centerY, + MatrixTypes.TRANSFORM_IS_SCALING | MatrixTypes.TRANSFORM_IS_TRANSLATION); + + return matrix; + } + + /// + /// Creates a scaling transform around the origin + /// + /// The scale factor in the x dimension + /// The scale factor in the y dimension + internal static Matrix CreateScaling(double scaleX, double scaleY) + { + Matrix matrix = new Matrix(); + matrix.SetMatrix(scaleX, 0, + 0, scaleY, + 0, 0, + MatrixTypes.TRANSFORM_IS_SCALING); + return matrix; + } + + /// + /// Creates a skew transform + /// + /// The skew angle in the x dimension in degrees + /// The skew angle in the y dimension in degrees + internal static Matrix CreateSkewRadians(double skewX, double skewY) + { + Matrix matrix = new Matrix(); + + matrix.SetMatrix(1.0, Math.Tan(skewY), + Math.Tan(skewX), 1.0, + 0.0, 0.0, + MatrixTypes.TRANSFORM_IS_UNKNOWN); + + return matrix; + } + + /// + /// Sets the transformation to the given translation specified by the offset vector. + /// + /// The offset in X + /// The offset in Y + internal static Matrix CreateTranslation(double offsetX, double offsetY) + { + Matrix matrix = new Matrix(); + + matrix.SetMatrix(1, 0, + 0, 1, + offsetX, offsetY, + MatrixTypes.TRANSFORM_IS_TRANSLATION); + + return matrix; + } + + #endregion Internal Methods + + #region Private Methods + /// + /// Sets the transformation to the identity. + /// + private static Matrix CreateIdentity() + { + Matrix matrix = new Matrix(); + matrix.SetMatrix(1, 0, + 0, 1, + 0, 0, + MatrixTypes.TRANSFORM_IS_IDENTITY); + return matrix; + } + + /// + /// Sets the transform to + /// / m11, m12, 0 \ + /// | m21, m22, 0 | + /// \ offsetX, offsetY, 1 / + /// where offsetX, offsetY is the translation. + /// + private void SetMatrix(double m11, double m12, + double m21, double m22, + double offsetX, double offsetY, + MatrixTypes type) + { + this._m11 = m11; + this._m12 = m12; + this._m21 = m21; + this._m22 = m22; + this._offsetX = offsetX; + this._offsetY = offsetY; + this._type = type; + } + + /// + /// Set the type of the matrix based on its current contents + /// + private void DeriveMatrixType() + { + _type = 0; + + // Now classify our matrix. + if (!(_m21 == 0 && _m12 == 0)) + { + _type = MatrixTypes.TRANSFORM_IS_UNKNOWN; + return; + } + + if (!(_m11 == 1 && _m22 == 1)) + { + _type = MatrixTypes.TRANSFORM_IS_SCALING; + } + + if (!(_offsetX == 0 && _offsetY == 0)) + { + _type |= MatrixTypes.TRANSFORM_IS_TRANSLATION; + } + + if (0 == (_type & (MatrixTypes.TRANSFORM_IS_TRANSLATION | MatrixTypes.TRANSFORM_IS_SCALING))) + { + // We have an identity matrix. + _type = MatrixTypes.TRANSFORM_IS_IDENTITY; + } + return; + } + + /// + /// Asserts that the matrix tag is one of the valid options and + /// that coefficients are correct. + /// + [Conditional("DEBUG")] + private void Debug_CheckType() + { + switch (_type) + { + case MatrixTypes.TRANSFORM_IS_IDENTITY: + return; + case MatrixTypes.TRANSFORM_IS_UNKNOWN: + return; + case MatrixTypes.TRANSFORM_IS_SCALING: + Debug.Assert(_m21 == 0); + Debug.Assert(_m12 == 0); + Debug.Assert(_offsetX == 0); + Debug.Assert(_offsetY == 0); + return; + case MatrixTypes.TRANSFORM_IS_TRANSLATION: + Debug.Assert(_m21 == 0); + Debug.Assert(_m12 == 0); + Debug.Assert(_m11 == 1); + Debug.Assert(_m22 == 1); + return; + case MatrixTypes.TRANSFORM_IS_SCALING | MatrixTypes.TRANSFORM_IS_TRANSLATION: + Debug.Assert(_m21 == 0); + Debug.Assert(_m12 == 0); + return; + default: + Debug.Assert(false); + return; + } + } + + #endregion Private Methods + + #region Private Properties and Fields + + /// + /// Efficient but conservative test for identity. Returns + /// true if the the matrix is identity. If it returns false + /// the matrix may still be identity. + /// + private bool IsDistinguishedIdentity + { + get + { + return _type == MatrixTypes.TRANSFORM_IS_IDENTITY; + } + } + + // The hash code for a matrix is the xor of its element's hashes. + // Since the identity matrix has 2 1's and 4 0's its hash is 0. + private const int c_identityHashCode = 0; + + #endregion Private Properties and Fields + + internal double _m11; + internal double _m12; + internal double _m21; + internal double _m22; + internal double _offsetX; + internal double _offsetY; + internal MatrixTypes _type; + + // This field is only used by unmanaged code which isn't detected by the compiler. +#pragma warning disable 0414 + // Matrix in blt'd to unmanaged code, so this is padding + // to align structure. + // + // Testing note: Validate that this blt will work on 64-bit + // + internal Int32 _padding; +#pragma warning restore 0414 + public string ToString(string? format, IFormatProvider? formatProvider) + { + return ""; + } + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/WindowsBase/MatrixUtil.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/WindowsBase/MatrixUtil.cs new file mode 100644 index 0000000..20d7406 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/WindowsBase/MatrixUtil.cs @@ -0,0 +1,212 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// +// +// +// Description: This file contains the implementation of MatrixUtil, which +// provides matrix multiply code. +// +// +// +// + +using System; +using System.Windows; +using System.Diagnostics; +using System.Security; +using WpfInk.WindowsBase.System.Windows.Media; +#if WINDOWS_BASE + using MS.Internal.WindowsBase; +#elif PRESENTATION_CORE + using MS.Internal.PresentationCore; +#elif PRESENTATIONFRAMEWORK + using MS.Internal.PresentationFramework; +#elif DRT + using MS.Internal.Drt; +#else +//#error Attempt to use FriendAccessAllowedAttribute from an unknown assembly. +#endif + +namespace MS.Internal +{ + // MatrixTypes + [System.Flags] + internal enum MatrixTypes + { + TRANSFORM_IS_IDENTITY = 0, + TRANSFORM_IS_TRANSLATION = 1, + TRANSFORM_IS_SCALING = 2, + TRANSFORM_IS_UNKNOWN = 4 + } + + internal static class MatrixUtil + { + /// + /// Multiplies two transformations, where the behavior is matrix1 *= matrix2. + /// This code exists so that we can efficient combine matrices without copying + /// the data around, since each matrix is 52 bytes. + /// To reduce duplication and to ensure consistent behavior, this is the + /// method which is used to implement Matrix * Matrix as well. + /// + internal static void MultiplyMatrix(ref Matrix matrix1, ref Matrix matrix2) + { + MatrixTypes type1 = matrix1._type; + MatrixTypes type2 = matrix2._type; + + // Check for idents + + // If the second is ident, we can just return + if (type2 == MatrixTypes.TRANSFORM_IS_IDENTITY) + { + return; + } + + // If the first is ident, we can just copy the memory across. + if (type1 == MatrixTypes.TRANSFORM_IS_IDENTITY) + { + matrix1 = matrix2; + return; + } + + // Optimize for translate case, where the second is a translate + if (type2 == MatrixTypes.TRANSFORM_IS_TRANSLATION) + { + // 2 additions + matrix1._offsetX += matrix2._offsetX; + matrix1._offsetY += matrix2._offsetY; + + // If matrix 1 wasn't unknown we added a translation + if (type1 != MatrixTypes.TRANSFORM_IS_UNKNOWN) + { + matrix1._type |= MatrixTypes.TRANSFORM_IS_TRANSLATION; + } + + return; + } + + // Check for the first value being a translate + if (type1 == MatrixTypes.TRANSFORM_IS_TRANSLATION) + { + // Save off the old offsets + double offsetX = matrix1._offsetX; + double offsetY = matrix1._offsetY; + + // Copy the matrix + matrix1 = matrix2; + + matrix1._offsetX = offsetX * matrix2._m11 + offsetY * matrix2._m21 + matrix2._offsetX; + matrix1._offsetY = offsetX * matrix2._m12 + offsetY * matrix2._m22 + matrix2._offsetY; + + if (type2 == MatrixTypes.TRANSFORM_IS_UNKNOWN) + { + matrix1._type = MatrixTypes.TRANSFORM_IS_UNKNOWN; + } + else + { + matrix1._type = MatrixTypes.TRANSFORM_IS_SCALING | MatrixTypes.TRANSFORM_IS_TRANSLATION; + } + return; + } + + // The following code combines the type of the transformations so that the high nibble + // is "this"'s type, and the low nibble is mat's type. This allows for a switch rather + // than nested switches. + + // trans1._type | trans2._type + // 7 6 5 4 | 3 2 1 0 + int combinedType = ((int) type1 << 4) | (int) type2; + + switch (combinedType) + { + case 34: // S * S + // 2 multiplications + matrix1._m11 *= matrix2._m11; + matrix1._m22 *= matrix2._m22; + return; + + case 35: // S * S|T + matrix1._m11 *= matrix2._m11; + matrix1._m22 *= matrix2._m22; + matrix1._offsetX = matrix2._offsetX; + matrix1._offsetY = matrix2._offsetY; + + // Transform set to Translate and Scale + matrix1._type = MatrixTypes.TRANSFORM_IS_TRANSLATION | MatrixTypes.TRANSFORM_IS_SCALING; + return; + + case 50: // S|T * S + matrix1._m11 *= matrix2._m11; + matrix1._m22 *= matrix2._m22; + matrix1._offsetX *= matrix2._m11; + matrix1._offsetY *= matrix2._m22; + return; + + case 51: // S|T * S|T + matrix1._m11 *= matrix2._m11; + matrix1._m22 *= matrix2._m22; + matrix1._offsetX = matrix2._m11 * matrix1._offsetX + matrix2._offsetX; + matrix1._offsetY = matrix2._m22 * matrix1._offsetY + matrix2._offsetY; + return; + case 36: // S * U + case 52: // S|T * U + case 66: // U * S + case 67: // U * S|T + case 68: // U * U + matrix1 = new Matrix( + matrix1._m11 * matrix2._m11 + matrix1._m12 * matrix2._m21, + matrix1._m11 * matrix2._m12 + matrix1._m12 * matrix2._m22, + + matrix1._m21 * matrix2._m11 + matrix1._m22 * matrix2._m21, + matrix1._m21 * matrix2._m12 + matrix1._m22 * matrix2._m22, + + matrix1._offsetX * matrix2._m11 + matrix1._offsetY * matrix2._m21 + matrix2._offsetX, + matrix1._offsetX * matrix2._m12 + matrix1._offsetY * matrix2._m22 + matrix2._offsetY); + return; +#if DEBUG + default: + Debug.Fail("Matrix multiply hit an invalid case: " + combinedType); + break; +#endif + } + } + + /// + /// Applies an offset to the specified matrix in place. + /// + internal static void PrependOffset( + ref Matrix matrix, + double offsetX, + double offsetY) + { + if (matrix._type == MatrixTypes.TRANSFORM_IS_IDENTITY) + { + matrix = new Matrix(1, 0, 0, 1, offsetX, offsetY); + matrix._type = MatrixTypes.TRANSFORM_IS_TRANSLATION; + } + else + { + // + // / 1 0 0 \ / m11 m12 0 \ + // | 0 1 0 | * | m21 m22 0 | + // \ tx ty 1 / \ ox oy 1 / + // + // / m11 m12 0 \ + // = | m21 m22 0 | + // \ m11*tx+m21*ty+ox m12*tx + m22*ty + oy 1 / + // + + matrix._offsetX += matrix._m11 * offsetX + matrix._m21 * offsetY; + matrix._offsetY += matrix._m12 * offsetX + matrix._m22 * offsetY; + + // It just gained a translate if was a scale transform. Identity transform is handled above. + Debug.Assert(matrix._type != MatrixTypes.TRANSFORM_IS_IDENTITY); + if (matrix._type != MatrixTypes.TRANSFORM_IS_UNKNOWN) + { + matrix._type |= MatrixTypes.TRANSFORM_IS_TRANSLATION; + } + } + } + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/ref/DoubleUtil.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/ref/DoubleUtil.cs new file mode 100644 index 0000000..a5c64f2 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/ref/DoubleUtil.cs @@ -0,0 +1,270 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// +// +// +// Description: This file contains the implementation of DoubleUtil, which +// provides "fuzzy" comparison functionality for doubles and +// double-based classes and structs in our code. +// +// +// +// +// + +using System; +using System.Windows; +using System.Runtime.InteropServices; +using WpfInk.PresentationCore.System.Windows; + +#if WINDOWS_BASE + using MS.Internal.WindowsBase; +#elif PRESENTATION_CORE + using MS.Internal.PresentationCore; +#elif PRESENTATIONFRAMEWORK + using MS.Internal.PresentationFramework; +#elif DRT + using MS.Internal.Drt; +#else +//#error Attempt to use FriendAccessAllowedAttribute from an unknown assembly. +#endif + +namespace MS.Internal +{ + internal static class DoubleUtil + { + // Const values come from sdk\inc\crt\float.h + internal const double DBL_EPSILON = 2.2204460492503131e-016; /* smallest such that 1.0+DBL_EPSILON != 1.0 */ + internal const float FLT_MIN = 1.175494351e-38F; /* Number close to zero, where float.MinValue is -float.MaxValue */ + + /// + /// AreClose - Returns whether or not two doubles are "close". That is, whether or + /// not they are within epsilon of each other. Note that this epsilon is proportional + /// to the numbers themselves to that AreClose survives scalar multiplication. + /// There are plenty of ways for this to return false even for numbers which + /// are theoretically identical, so no code calling this should fail to work if this + /// returns false. This is important enough to repeat: + /// NB: NO CODE CALLING THIS FUNCTION SHOULD DEPEND ON ACCURATE RESULTS - this should be + /// used for optimizations *only*. + /// + /// + /// bool - the result of the AreClose comparision. + /// + /// The first double to compare. + /// The second double to compare. + public static bool AreClose(double value1, double value2) + { + //in case they are Infinities (then epsilon check does not work) + if (value1 == value2) return true; + // This computes (|value1-value2| / (|value1| + |value2| + 10.0)) < DBL_EPSILON + double eps = (Math.Abs(value1) + Math.Abs(value2) + 10.0) * DBL_EPSILON; + double delta = value1 - value2; + return (-eps < delta) && (eps > delta); + } + + /// + /// LessThan - Returns whether or not the first double is less than the second double. + /// That is, whether or not the first is strictly less than *and* not within epsilon of + /// the other number. Note that this epsilon is proportional to the numbers themselves + /// to that AreClose survives scalar multiplication. Note, + /// There are plenty of ways for this to return false even for numbers which + /// are theoretically identical, so no code calling this should fail to work if this + /// returns false. This is important enough to repeat: + /// NB: NO CODE CALLING THIS FUNCTION SHOULD DEPEND ON ACCURATE RESULTS - this should be + /// used for optimizations *only*. + /// + /// + /// bool - the result of the LessThan comparision. + /// + /// The first double to compare. + /// The second double to compare. + public static bool LessThan(double value1, double value2) + { + return (value1 < value2) && !AreClose(value1, value2); + } + + + /// + /// GreaterThan - Returns whether or not the first double is greater than the second double. + /// That is, whether or not the first is strictly greater than *and* not within epsilon of + /// the other number. Note that this epsilon is proportional to the numbers themselves + /// to that AreClose survives scalar multiplication. Note, + /// There are plenty of ways for this to return false even for numbers which + /// are theoretically identical, so no code calling this should fail to work if this + /// returns false. This is important enough to repeat: + /// NB: NO CODE CALLING THIS FUNCTION SHOULD DEPEND ON ACCURATE RESULTS - this should be + /// used for optimizations *only*. + /// + /// + /// bool - the result of the GreaterThan comparision. + /// + /// The first double to compare. + /// The second double to compare. + public static bool GreaterThan(double value1, double value2) + { + return (value1 > value2) && !AreClose(value1, value2); + } + + /// + /// LessThanOrClose - Returns whether or not the first double is less than or close to + /// the second double. That is, whether or not the first is strictly less than or within + /// epsilon of the other number. Note that this epsilon is proportional to the numbers + /// themselves to that AreClose survives scalar multiplication. Note, + /// There are plenty of ways for this to return false even for numbers which + /// are theoretically identical, so no code calling this should fail to work if this + /// returns false. This is important enough to repeat: + /// NB: NO CODE CALLING THIS FUNCTION SHOULD DEPEND ON ACCURATE RESULTS - this should be + /// used for optimizations *only*. + /// + /// + /// bool - the result of the LessThanOrClose comparision. + /// + /// The first double to compare. + /// The second double to compare. + public static bool LessThanOrClose(double value1, double value2) + { + return (value1 < value2) || AreClose(value1, value2); + } + + /// + /// GreaterThanOrClose - Returns whether or not the first double is greater than or close to + /// the second double. That is, whether or not the first is strictly greater than or within + /// epsilon of the other number. Note that this epsilon is proportional to the numbers + /// themselves to that AreClose survives scalar multiplication. Note, + /// There are plenty of ways for this to return false even for numbers which + /// are theoretically identical, so no code calling this should fail to work if this + /// returns false. This is important enough to repeat: + /// NB: NO CODE CALLING THIS FUNCTION SHOULD DEPEND ON ACCURATE RESULTS - this should be + /// used for optimizations *only*. + /// + /// + /// bool - the result of the GreaterThanOrClose comparision. + /// + /// The first double to compare. + /// The second double to compare. + public static bool GreaterThanOrClose(double value1, double value2) + { + return (value1 > value2) || AreClose(value1, value2); + } + + /// + /// IsOne - Returns whether or not the double is "close" to 1. Same as AreClose(double, 1), + /// but this is faster. + /// + /// + /// bool - the result of the AreClose comparision. + /// + /// The double to compare to 1. + public static bool IsOne(double value) + { + return Math.Abs(value - 1.0) < 10.0 * DBL_EPSILON; + } + + /// + /// IsZero - Returns whether or not the double is "close" to 0. Same as AreClose(double, 0), + /// but this is faster. + /// + /// + /// bool - the result of the AreClose comparision. + /// + /// The double to compare to 0. + public static bool IsZero(double value) + { + return Math.Abs(value) < 10.0 * DBL_EPSILON; + } + + // The Point, Size, Rect and Matrix class have moved to WinCorLib. However, we provide + // internal AreClose methods for our own use here. + + /// + /// Compares two points for fuzzy equality. This function + /// helps compensate for the fact that double values can + /// acquire error when operated upon + /// + /// The first point to compare + /// The second point to compare + /// Whether or not the two points are equal + public static bool AreClose(Point point1, Point point2) + { + return DoubleUtil.AreClose(point1.X, point2.X) && + DoubleUtil.AreClose(point1.Y, point2.Y); + } + + /// + /// Compares two Size instances for fuzzy equality. This function + /// helps compensate for the fact that double values can + /// acquire error when operated upon + /// + /// The first size to compare + /// The second size to compare + /// Whether or not the two Size instances are equal + public static bool AreClose(Size size1, Size size2) + { + return DoubleUtil.AreClose(size1.Width, size2.Width) && + DoubleUtil.AreClose(size1.Height, size2.Height); + } + + /// + /// Compares two Vector instances for fuzzy equality. This function + /// helps compensate for the fact that double values can + /// acquire error when operated upon + /// + /// The first Vector to compare + /// The second Vector to compare + /// Whether or not the two Vector instances are equal + public static bool AreClose(Vector vector1, Vector vector2) + { + return DoubleUtil.AreClose(vector1.X, vector2.X) && + DoubleUtil.AreClose(vector1.Y, vector2.Y); + } + + /// + /// + /// + /// + /// + public static bool IsBetweenZeroAndOne(double val) + { + return (GreaterThanOrClose(val, 0) && LessThanOrClose(val, 1)); + } + + /// + /// + /// + /// + /// + public static int DoubleToInt(double val) + { + return (0 < val) ? (int) (val + 0.5) : (int) (val - 0.5); + } + + +#if !PBTCOMPILER + + [StructLayout(LayoutKind.Explicit)] + private struct NanUnion + { + [FieldOffset(0)] internal double DoubleValue; + [FieldOffset(0)] internal UInt64 UintValue; + } + + // The standard CLR double.IsNaN() function is approximately 100 times slower than our own wrapper, + // so please make sure to use DoubleUtil.IsNaN() in performance sensitive code. + // PS item that tracks the CLR improvement is DevDiv Schedule : 26916. + // IEEE 754 : If the argument is any value in the range 0x7ff0000000000001L through 0x7fffffffffffffffL + // or in the range 0xfff0000000000001L through 0xffffffffffffffffL, the result will be NaN. + public static bool IsNaN(double value) + { + NanUnion t = new NanUnion(); + t.DoubleValue = value; + + UInt64 exp = t.UintValue & 0xfff0000000000000; + UInt64 man = t.UintValue & 0x000fffffffffffff; + + return (exp == 0x7ff0000000000000 || exp == 0xfff0000000000000) && (man != 0); + } +#endif + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/ref/Generated/Matrix.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/ref/Generated/Matrix.cs new file mode 100644 index 0000000..bb08d6d --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/ref/Generated/Matrix.cs @@ -0,0 +1,233 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// +// +// This file was generated, please do not edit it directly. +// +// Please see MilCodeGen.html for more information. +// + +using System; +//using System.Windows.Media.Converters; +// These types are aliased to match the unamanaged names used in interop + +namespace WpfInk.WindowsBase.System.Windows.Media +{ + partial struct Matrix + { + #region Public Methods + + /// + /// Compares two Matrix instances for exact equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which are logically equal may fail. + /// Furthermore, using this equality operator, Double.NaN is not equal to itself. + /// + /// + /// bool - true if the two Matrix instances are exactly equal, false otherwise + /// + /// The first Matrix to compare + /// The second Matrix to compare + public static bool operator ==(Matrix matrix1, Matrix matrix2) + { + if (matrix1.IsDistinguishedIdentity || matrix2.IsDistinguishedIdentity) + { + return matrix1.IsIdentity == matrix2.IsIdentity; + } + else + { + return matrix1.M11 == matrix2.M11 && + matrix1.M12 == matrix2.M12 && + matrix1.M21 == matrix2.M21 && + matrix1.M22 == matrix2.M22 && + matrix1.OffsetX == matrix2.OffsetX && + matrix1.OffsetY == matrix2.OffsetY; + } + } + + /// + /// Compares two Matrix instances for exact inequality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which are logically equal may fail. + /// Furthermore, using this equality operator, Double.NaN is not equal to itself. + /// + /// + /// bool - true if the two Matrix instances are exactly unequal, false otherwise + /// + /// The first Matrix to compare + /// The second Matrix to compare + public static bool operator !=(Matrix matrix1, Matrix matrix2) + { + return !(matrix1 == matrix2); + } + + /// + /// Compares two Matrix instances for object equality. In this equality + /// Double.NaN is equal to itself, unlike in numeric equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which + /// are logically equal may fail. + /// + /// + /// bool - true if the two Matrix instances are exactly equal, false otherwise + /// + /// The first Matrix to compare + /// The second Matrix to compare + public static bool Equals(Matrix matrix1, Matrix matrix2) + { + if (matrix1.IsDistinguishedIdentity || matrix2.IsDistinguishedIdentity) + { + return matrix1.IsIdentity == matrix2.IsIdentity; + } + else + { + return matrix1.M11.Equals(matrix2.M11) && + matrix1.M12.Equals(matrix2.M12) && + matrix1.M21.Equals(matrix2.M21) && + matrix1.M22.Equals(matrix2.M22) && + matrix1.OffsetX.Equals(matrix2.OffsetX) && + matrix1.OffsetY.Equals(matrix2.OffsetY); + } + } + + /// + /// Equals - compares this Matrix with the passed in object. In this equality + /// Double.NaN is equal to itself, unlike in numeric equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which + /// are logically equal may fail. + /// + /// + /// bool - true if the object is an instance of Matrix and if it's equal to "this". + /// + /// The object to compare to "this" + public override bool Equals(object o) + { + if ((null == o) || !(o is Matrix)) + { + return false; + } + + Matrix value = (Matrix) o; + return Matrix.Equals(this, value); + } + + /// + /// Equals - compares this Matrix with the passed in object. In this equality + /// Double.NaN is equal to itself, unlike in numeric equality. + /// Note that double values can acquire error when operated upon, such that + /// an exact comparison between two values which + /// are logically equal may fail. + /// + /// + /// bool - true if "value" is equal to "this". + /// + /// The Matrix to compare to "this" + public bool Equals(Matrix value) + { + return Matrix.Equals(this, value); + } + + /// + /// Returns the HashCode for this Matrix + /// + /// + /// int - the HashCode for this Matrix + /// + public override int GetHashCode() + { + if (IsDistinguishedIdentity) + { + return c_identityHashCode; + } + else + { + // Perform field-by-field XOR of HashCodes + return M11.GetHashCode() ^ + M12.GetHashCode() ^ + M21.GetHashCode() ^ + M22.GetHashCode() ^ + OffsetX.GetHashCode() ^ + OffsetY.GetHashCode(); + } + } + + #endregion Public Methods + + #region Internal Properties + + /// + /// Creates a string representation of this object based on the current culture. + /// + /// + /// A string representation of this object. + /// + public override string ToString() + { + // Delegate to the internal method which implements all ToString calls. + return ConvertToString(null /* format string */, null /* format provider */); + } + + /// + /// Creates a string representation of this object based on the IFormatProvider + /// passed in. If the provider is null, the CurrentCulture is used. + /// + /// + /// A string representation of this object. + /// + public string ToString(IFormatProvider provider) + { + // Delegate to the internal method which implements all ToString calls. + return ConvertToString(null /* format string */, provider); + } + + /// + /// Creates a string representation of this object based on the format string + /// and IFormatProvider passed in. + /// If the provider is null, the CurrentCulture is used. + /// See the documentation for IFormattable for more information. + /// + /// + /// A string representation of this object. + /// + string IFormattable.ToString(string format, IFormatProvider provider) + { + // Delegate to the internal method which implements all ToString calls. + return ConvertToString(format, provider); + } + + /// + /// Creates a string representation of this object based on the format string + /// and IFormatProvider passed in. + /// If the provider is null, the CurrentCulture is used. + /// See the documentation for IFormattable for more information. + /// + /// + /// A string representation of this object. + /// + internal string ConvertToString(string format, IFormatProvider provider) + { + if (IsIdentity) + { + return "Identity"; + } + + // Helper to get the numeric list separator for a given culture. + char separator = ','; //MS.Internal.TokenizerHelper.GetNumericListSeparator(provider); + return String.Format(provider, + "{1:" + format + "}{0}{2:" + format + "}{0}{3:" + format + "}{0}{4:" + format + "}{0}{5:" + format + + "}{0}{6:" + format + "}", + separator, + _m11, + _m12, + _m21, + _m22, + _offsetX, + _offsetY); + } + + #endregion Internal Properties + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/ref/KnownIds.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/ref/KnownIds.cs new file mode 100644 index 0000000..e9ccd37 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/ref/KnownIds.cs @@ -0,0 +1,264 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections; +using System.IO; +using MS.Internal.Ink.InkSerializedFormat; + +namespace WpfInk.PresentationCore.System.Windows.Ink +{ + /// + /// [To be supplied.] + /// + internal static class KnownIds + { + #region Public Ids + /// + /// [To be supplied.] + /// + internal static readonly Guid X = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.X]; + /// + /// [To be supplied.] + /// + internal static readonly Guid Y = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.Y]; + /// + /// [To be supplied.] + /// + internal static readonly Guid Z = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.Z]; + /// + /// [To be supplied.] + /// + internal static readonly Guid PacketStatus = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.PacketStatus]; + /// + /// [To be supplied.] + /// + internal static readonly Guid TimerTick = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.TimerTick]; + /// + /// [To be supplied.] + /// + internal static readonly Guid SerialNumber = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.SerialNumber]; + /// + /// [To be supplied.] + /// + internal static readonly Guid NormalPressure = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.NormalPressure]; + /// + /// [To be supplied.] + /// + internal static readonly Guid TangentPressure = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.TangentPressure]; + /// + /// [To be supplied.] + /// + internal static readonly Guid ButtonPressure = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.ButtonPressure]; + /// + /// [To be supplied.] + /// + internal static readonly Guid XTiltOrientation = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.XTiltOrientation]; + /// + /// [To be supplied.] + /// + internal static readonly Guid YTiltOrientation = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.YTiltOrientation]; + /// + /// [To be supplied.] + /// + internal static readonly Guid AzimuthOrientation = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.AzimuthOrientation]; + /// + /// [To be supplied.] + /// + internal static readonly Guid AltitudeOrientation = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.AltitudeOrientation]; + /// + /// [To be supplied.] + /// + internal static readonly Guid TwistOrientation = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.TwistOrientation]; + /// + /// [To be supplied.] + /// + internal static readonly Guid PitchRotation = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.PitchRotation]; + /// + /// [To be supplied.] + /// + internal static readonly Guid RollRotation = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.RollRotation]; + /// + /// [To be supplied.] + /// + internal static readonly Guid YawRotation = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.YawRotation]; + /// + /// [To be supplied.] + /// + internal static readonly Guid Color = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.ColorRef]; + /// + /// [To be supplied.] + /// + internal static readonly Guid DrawingFlags = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.DrawingFlags]; + /// + /// [To be supplied.] + /// + internal static readonly Guid CursorId = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.CursorId]; + /// + /// [To be supplied.] + /// + internal static readonly Guid WordAlternates = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.WordAlternates]; + /// + /// [To be supplied.] + /// + internal static readonly Guid CharacterAlternates = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.CharAlternates]; + /// + /// [To be supplied.] + /// + internal static readonly Guid InkMetrics = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.InkMetrics]; + /// + /// [To be supplied.] + /// + internal static readonly Guid GuideStructure = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.GuideStructure]; + /// + /// [To be supplied.] + /// + internal static readonly Guid Timestamp = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.Timestamp]; + /// + /// [To be supplied.] + /// + internal static readonly Guid Language = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.Language]; + /// + /// [To be supplied.] + /// + internal static readonly Guid Transparency = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.Transparency]; + /// + /// [To be supplied.] + /// + internal static readonly Guid CurveFittingError = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.CurveFittingError]; + /// + /// [To be supplied.] + /// + internal static readonly Guid RecognizedLattice = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.RecoLattice]; + /// + /// [To be supplied.] + /// + internal static readonly Guid CursorDown = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.CursorDown]; + /// + /// [To be supplied.] + /// + internal static readonly Guid SecondaryTipSwitch = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.SecondaryTipSwitch]; + /// + /// [To be supplied.] + /// + internal static readonly Guid TabletPick = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.TabletPick]; + /// + /// [To be supplied.] + /// + internal static readonly Guid BarrelDown = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.BarrelDown]; + /// + /// [To be supplied.] + /// + internal static readonly Guid RasterOperation = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.RasterOperation]; + + /// + /// The height of the pen tip which affects the stroke rendering. + /// + internal static readonly Guid StylusHeight = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.StylusHeight]; + + /// + /// The width of the pen tip which affects the stroke rendering. + /// + internal static readonly Guid StylusWidth = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.StylusWidth]; + + /// + /// Guid identifying the highlighter property + /// + internal static readonly Guid Highlighter = KnownIdCache.TabletInternalIdTable[(int) KnownIdCache.TabletInternalIdIndex.Highlighter]; + /// + /// Guid identifying the Ink properties + /// + internal static readonly Guid InkProperties = KnownIdCache.TabletInternalIdTable[(int) KnownIdCache.TabletInternalIdIndex.InkProperties]; + /// + /// Guid identifying the Ink Style bold property + /// + internal static readonly Guid InkStyleBold = KnownIdCache.TabletInternalIdTable[(int) KnownIdCache.TabletInternalIdIndex.InkStyleBold]; + /// + /// Guid identifying the ink style italics property + /// + internal static readonly Guid InkStyleItalics = KnownIdCache.TabletInternalIdTable[(int) KnownIdCache.TabletInternalIdIndex.InkStyleItalics]; + /// + /// Guid identifying the stroke timestamp property + /// + internal static readonly Guid StrokeTimestamp = KnownIdCache.TabletInternalIdTable[(int) KnownIdCache.TabletInternalIdIndex.StrokeTimestamp]; + /// + /// Guid identifying the stroke timeid property + /// + internal static readonly Guid StrokeTimeId = KnownIdCache.TabletInternalIdTable[(int) KnownIdCache.TabletInternalIdIndex.StrokeTimeId]; + + /// + /// Guid identifying the StylusTip + /// + internal static readonly Guid StylusTip = new Guid(0x3526c731, 0xee79, 0x4988, 0xb9, 0x3e, 0x70, 0xd9, 0x2f, 0x89, 0x7, 0xed); + + /// + /// Guid identifying the StylusTipTransform + /// + internal static readonly Guid StylusTipTransform = new Guid(0x4b63bc16, 0x7bc4, 0x4fd2, 0x95, 0xda, 0xac, 0xff, 0x47, 0x75, 0x73, 0x2d); + + + /// + /// Guid identifying IsHighlighter + /// + internal static readonly Guid IsHighlighter = new Guid(0xce305e1a, 0xe08, 0x45e3, 0x8c, 0xdc, 0xe4, 0xb, 0xb4, 0x50, 0x6f, 0x21); + + // /// + // /// Guid used for identifying the fill-brush for rendering a stroke. + // /// + // public static readonly Guid FillBrush = new Guid(0x9a547c5c, 0x1fff, 0x4987, 0x8a, 0xb6, 0xbe, 0xed, 0x75, 0xde, 0xa, 0x1d); + // + // /// + // /// Guid used for identifying the pen used for rendering a stroke's outline. + // /// + // public static readonly Guid OutlinePen = new Guid(0x9967aea6, 0x3980, 0x4337, 0xb7, 0xc6, 0x34, 0xa, 0x33, 0x98, 0x8e, 0x6b); + // + // /// + // /// Guid used for identifying the blend mode used for rendering a stroke (similar to ROP in v1). + // /// + // public static readonly Guid BlendMode = new Guid(0xd6993943, 0x7a84, 0x4a80, 0x84, 0x68, 0xa8, 0x3c, 0xca, 0x65, 0xb0, 0x5); + // + // /// + // /// Guid used for identifying StylusShape object + // /// + // public static readonly Guid StylusShape = new Guid(0xf998e7f8, 0x7cdb, 0x4c0e, 0xb2, 0xe2, 0x63, 0x2b, 0xca, 0x21, 0x2a, 0x7b); + #endregion + + #region Internal Ids + + /// + /// The style of the rendering used for the pen tip. + /// + internal static readonly Guid PenStyle = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.PenStyle]; + + /// + /// The shape of the tip of the pen used for stroke rendering. + /// + internal static readonly Guid PenTip = KnownIdCache.OriginalISFIdTable[(int) KnownIdCache.OriginalISFIdIndex.PenTip]; + + /// + /// Guid used for identifying the Custom Stroke + /// + /// Should we hide the CustomStrokes and StrokeLattice data? + internal static readonly Guid InkCustomStrokes = KnownIdCache.TabletInternalIdTable[(int) KnownIdCache.TabletInternalIdIndex.InkCustomStrokes]; + + /// + /// Guid used for identifying the Stroke Lattice + /// + internal static readonly Guid InkStrokeLattice = KnownIdCache.TabletInternalIdTable[(int) KnownIdCache.TabletInternalIdIndex.InkStrokeLattice]; + +#if UNDO_ENABLED + /// + /// Guid used for identifying if an undo/event has already been handled + /// + /// {053BF717-DBE7-4e52-805E-64906138FAAD} + internal static readonly Guid UndoEventArgsHandled = new Guid(0x53bf717, 0xdbe7, 0x4e52, 0x80, 0x5e, 0x64, 0x90, 0x61, 0x38, 0xfa, 0xad); +#endif + #endregion + + #region Known Id Helpers + private static global::System.Reflection.MemberInfo[] PublicMemberInfo = null; + + #endregion + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/ref/MS.Internal.PresentationCore.SRID .cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/ref/MS.Internal.PresentationCore.SRID .cs new file mode 100644 index 0000000..562af65 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/ref/MS.Internal.PresentationCore.SRID .cs @@ -0,0 +1,1615 @@ +// +using System.Reflection; + +namespace FxResources.PresentationCore +{ + internal static class SR { } +} +namespace MS.Internal.PresentationCore +{ + internal static partial class SRID + { + private static global::System.Resources.ResourceManager s_resourceManager; + internal static global::System.Resources.ResourceManager ResourceManager => s_resourceManager ?? (s_resourceManager = new global::System.Resources.ResourceManager(typeof(FxResources.PresentationCore.SR))); + internal static global::System.Globalization.CultureInfo Culture { get; set; } +#if !NET20 + [global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] +#endif + internal static string GetResourceString(string resourceKey, string defaultValue = null) => ResourceManager.GetString(resourceKey, Culture); + /// '{0}' is not a single Unicode character. + internal const string @AccessKeyManager_NotAUnicodeCharacter = "AccessKeyManager_NotAUnicodeCharacter"; + /// Text formatting engine cannot acquire break record due to error: '{0}'. + internal const string @AcquireBreakRecordFailure = "AcquireBreakRecordFailure"; + /// Text formatting engine cannot acquire text penalty module due to error: '{0}'. + internal const string @AcquirePenaltyModuleFailure = "AcquirePenaltyModuleFailure"; + /// Cannot add text to '{0}'. + internal const string @AddText_Invalid = "AddText_Invalid"; + /// If AllGestures is specified, it must be the only ApplicationGesture in the ApplicationGesture array. + internal const string @AllGesturesMustExistAlone = "AllGesturesMustExistAlone"; + /// AnimationTimeline of type '{0}' cannot be used to animate the '{1}' property of type '{2}'. + internal const string @Animation_AnimationTimelineTypeMismatch = "Animation_AnimationTimelineTypeMismatch"; + /// The animation(s) applied to the '{0}' property calculate a current value of '{1}', which is not a valid value for the property. + internal const string @Animation_CalculatedValueIsInvalidForProperty = "Animation_CalculatedValueIsInvalidForProperty"; + /// A child of KeyFrameAnimation in XAML must be a KeyFrame of a compatible type. + internal const string @Animation_ChildMustBeKeyFrame = "Animation_ChildMustBeKeyFrame"; + /// One of the animations in the timeline is a '{0}' and cannot be used to animate a property of type '{1}'. + internal const string @Animation_ChildTypeMismatch = "Animation_ChildTypeMismatch"; + /// '{0}' property is not animatable on '{1}' class because the IsAnimationProhibited flag has been set on the UIPropertyMetadata used to associate the property with the class. + internal const string @Animation_DependencyPropertyIsNotAnimatable = "Animation_DependencyPropertyIsNotAnimatable"; + /// Cannot animate the '{0}' property on a '{1}' using a '{2}'. For details see the inner exception. + internal const string @Animation_Exception = "Animation_Exception"; + /// '{0}' is not a valid '{1}' value for class '{2}'. This value might have been supplied by the base value of the property being animated or the output value of another animation applied to the same property. + internal const string @Animation_InvalidBaseValue = "Animation_InvalidBaseValue"; + /// Resolved KeyTime for key frame at index {1} cannot be greater than resolved KeyTime for key frame at index {4}. KeyFrames[{1}] has specified KeyTime '{2}', which resolves to time {3}Animation_InvalidAnimationUsingKeyFramesDuration + internal const string @Animation_InvalidResolvedKeyTimes = "Animation_InvalidResolvedKeyTimes"; + /// '{2}' KeyTime value is not valid for key frame at index {1} of this '{0}' because it is greater than animation's Duration value '{3}'. + internal const string @Animation_InvalidTimeKeyTime = "Animation_InvalidTimeKeyTime"; + /// '{0}' cannot use default {1} value of '{2}'. + internal const string @Animation_Invalid_DefaultValue = "Animation_Invalid_DefaultValue"; + /// Cannot set '{0}' to '{1}'. KeySpline values must be between 0.0 and 1.0. + internal const string @Animation_KeySpline_InvalidValue = "Animation_KeySpline_InvalidValue"; + /// '{0}' is not a valid Percent value for a KeyTime. The Percent value must be a number from 0.0 to 1.0. + internal const string @Animation_KeyTime_InvalidPercentValue = "Animation_KeyTime_InvalidPercentValue"; + /// Cannot create a KeyTime with the value '{0}' because it is less than zero. + internal const string @Animation_KeyTime_LessThanZero = "Animation_KeyTime_LessThanZero"; + /// '{0}' value is not valid because it contains no animations. + internal const string @Animation_NoAnimationsSpecified = "Animation_NoAnimationsSpecified"; + /// KeyFrameAnimation objects cannot have text objects as children. + internal const string @Animation_NoTextChildren = "Animation_NoTextChildren"; + /// A '{0}' on the '{1}' property of a '{2}' returned a current value of UnsetValue.Instance, which is not valid. + internal const string @Animation_ReturnedUnsetValueInstance = "Animation_ReturnedUnsetValueInstance"; + /// The HandoffBehavior value is not valid. + internal const string @Animation_UnrecognizedHandoffBehavior = "Animation_UnrecognizedHandoffBehavior"; + /// This AnimationEffect is already attached to a UIElement. + internal const string @AnimEffect_AlreadyAttached = "AnimEffect_AlreadyAttached"; + /// This AnimationEffectCollection is already being used by another UIElement. + internal const string @AnimEffect_CollectionInUse = "AnimEffect_CollectionInUse"; + /// This AnimationEffect is not attached to a Visual. + internal const string @AnimEffect_NoVisual = "AnimEffect_NoVisual"; + /// The ApplicationGesture array must contain at least one member. + internal const string @ApplicationGestureArrayLengthIsZero = "ApplicationGestureArrayLengthIsZero"; + /// The specified ApplicationGesture is not valid. + internal const string @ApplicationGestureIsInvalid = "ApplicationGestureIsInvalid"; + /// Automation client cannot access UI because application is shutting down. + internal const string @AutomationDispatcherShutdown = "AutomationDispatcherShutdown"; + /// Timeout occurred while attempting to access UI. The application might be busy or unresponsive. + internal const string @AutomationTimeout = "AutomationTimeout"; + /// '{0}' is not a valid System.Windows.Automation.AutomationPeer. It is expected to be associated with a Window known to Automation. + internal const string @Automation_InvalidConnectedPeer = "Automation_InvalidConnectedPeer"; + /// '{0}' is not a valid System.Windows.Automation.AutomationEvent. + internal const string @Automation_InvalidEventId = "Automation_InvalidEventId"; + /// '{0}' is not a valid System.Windows.Automation.SynchronizedInputType. + internal const string @Automation_InvalidSynchronizedInputType = "Automation_InvalidSynchronizedInputType"; + /// Recursive call to Automation Peer API is not valid. + internal const string @Automation_RecursivePublicCall = "Automation_RecursivePublicCall"; + /// Unsupported UI Automation event association. + internal const string @Automation_UnsupportedUIAutomationEventAssociation = "Automation_UnsupportedUIAutomationEventAssociation"; + /// BitmapCacheBrush does not support Opacity. + internal const string @BitmapCacheBrush_OpacityChanged = "BitmapCacheBrush_OpacityChanged"; + /// BitmapCacheBrush does not support RelativeTransform. + internal const string @BitmapCacheBrush_RelativeTransformChanged = "BitmapCacheBrush_RelativeTransformChanged"; + /// BitmapCacheBrush does not support Transform. + internal const string @BitmapCacheBrush_TransformChanged = "BitmapCacheBrush_TransformChanged"; + /// Alt+Left;Backspace + internal const string @BrowseBackKeyDisplayString = "BrowseBackKeyDisplayString"; + /// Back + internal const string @BrowseBackText = "BrowseBackText"; + /// Alt+Right;Shift+Backspace + internal const string @BrowseForwardKeyDisplayString = "BrowseForwardKeyDisplayString"; + /// Forward + internal const string @BrowseForwardText = "BrowseForwardText"; + /// Alt+Home;BrowserHome + internal const string @BrowseHomeKeyDisplayString = "BrowseHomeKeyDisplayString"; + /// Home + internal const string @BrowseHomeText = "BrowseHomeText"; + /// Alt+Esc;BrowserStop + internal const string @BrowseStopKeyDisplayString = "BrowseStopKeyDisplayString"; + /// Stop + internal const string @BrowseStopText = "BrowseStopText"; + /// Unrecognized brush type in BAML file. + internal const string @BrushUnknownBamlType = "BrushUnknownBamlType"; + /// Cannot access a disposed HTTP byte range downloader. + internal const string @ByteRangeDownloaderDisposed = "ByteRangeDownloaderDisposed"; + /// Byte range request failed. + internal const string @ByteRangeDownloaderErroredOut = "ByteRangeDownloaderErroredOut"; + /// Server does not support byte range request. + internal const string @ByteRangeRequestIsNotSupported = "ByteRangeRequestIsNotSupported"; + /// Cancel Print + internal const string @CancelPrintText = "CancelPrintText"; + /// Cannot attach a Visual that is already attached. + internal const string @CannotAttachVisualTwice = "CannotAttachVisualTwice"; + /// '{0}' and '{1}' cannot both be null. + internal const string @CannotBothBeNull = "CannotBothBeNull"; + /// Cannot convert string value '{0}' to type '{1}'. + internal const string @CannotConvertStringToType = "CannotConvertStringToType"; + /// Cannot convert type '{0}' to '{1}'. + internal const string @CannotConvertType = "CannotConvertType"; + /// Cannot modify a read-only container. + internal const string @CannotModifyReadOnlyContainer = "CannotModifyReadOnlyContainer"; + /// Cannot modify the Visual children for this node because a tree walk is in progress. + internal const string @CannotModifyVisualChildrenDuringTreeWalk = "CannotModifyVisualChildrenDuringTreeWalk"; + /// Cannot navigate to application resource '{0}' by using a WebBrowser control. For URI navigation, the resource must be at the application's site of origin. Use the pack://siteoforigin:,,,/ prefix to avoid hard-coding the URI. + internal const string @CannotNavigateToApplicationResourcesInWebBrowser = "CannotNavigateToApplicationResourcesInWebBrowser"; + /// Cannot get part or part information from a write-only container. + internal const string @CannotRetrievePartsOfWriteOnlyContainer = "CannotRetrievePartsOfWriteOnlyContainer"; + /// Cannot read from the specified command buffer pointer. + internal const string @Channel_InvalidCommandBufferPointer = "Channel_InvalidCommandBufferPointer"; + /// The Metrics property of CharacterMetrics is missing a required field. + internal const string @CharacterMetrics_MissingRequiredField = "CharacterMetrics_MissingRequiredField"; + /// CharacterMetrics is not valid. The horizontal advance (defined as the sum of BlackBoxWidth, LeftSideBearing, and RightSideBearing) cannot be negative. + internal const string @CharacterMetrics_NegativeHorizontalAdvance = "CharacterMetrics_NegativeHorizontalAdvance"; + /// CharacterMetrics is not valid. The vertical advance (defined as the sum of BlackBoxHeight, TopSideBearing, and BottomSideBearing) cannot be negative. + internal const string @CharacterMetrics_NegativeVerticalAdvance = "CharacterMetrics_NegativeVerticalAdvance"; + /// The Metrics property of CharacterMetrics has too many fields. + internal const string @CharacterMetrics_TooManyFields = "CharacterMetrics_TooManyFields"; + /// Class handlers can be registered only for UIElement or ContentElement and their subtypes. + internal const string @ClassTypeIllegal = "ClassTypeIllegal"; + /// Text formatting engine cannot clone break record due to error: '{0}'. + internal const string @CloneBreakRecordFailure = "CloneBreakRecordFailure"; + /// Close + internal const string @CloseText = "CloseText"; + /// A cluster map entry must be greater than or equal to a previous entry. + internal const string @ClusterMapEntriesShouldNotDecrease = "ClusterMapEntriesShouldNotDecrease"; + /// A cluster map entry must point to a valid glyph indices element. + internal const string @ClusterMapEntryShouldPointWithinGlyphIndices = "ClusterMapEntryShouldPointWithinGlyphIndices"; + /// The first element in the cluster map must equal zero. + internal const string @ClusterMapFirstEntryMustBeZero = "ClusterMapFirstEntryMustBeZero"; + /// '{0}' character is outside the Unicode code point range. + internal const string @CodePointOutOfRange = "CodePointOutOfRange"; + /// '{0}' key already exists in the collection. + internal const string @CollectionDuplicateKey = "CollectionDuplicateKey"; + /// Collection was modified during enumeration. + internal const string @CollectionEnumerationError = "CollectionEnumerationError"; + /// This collection is fixed size. + internal const string @CollectionIsFixedSize = "CollectionIsFixedSize"; + /// The number of elements in this collection must be greater than zero. + internal const string @CollectionNumberOfElementsMustBeGreaterThanZero = "CollectionNumberOfElementsMustBeGreaterThanZero"; + /// The number of elements in this collection must be less than or equal to '{0}'. + internal const string @CollectionNumberOfElementsMustBeLessOrEqualTo = "CollectionNumberOfElementsMustBeLessOrEqualTo"; + /// The number of elements in this collection should equal '{0}'. + internal const string @CollectionNumberOfElementsShouldBeEqualTo = "CollectionNumberOfElementsShouldBeEqualTo"; + /// Collection accepts only objects of type CommandBinding. + internal const string @CollectionOnlyAcceptsCommandBindings = "CollectionOnlyAcceptsCommandBindings"; + /// Collection accepts only objects of type InputBinding. + internal const string @CollectionOnlyAcceptsInputBindings = "CollectionOnlyAcceptsInputBindings"; + /// Collection accepts only objects of type InputGesture. + internal const string @CollectionOnlyAcceptsInputGestures = "CollectionOnlyAcceptsInputGestures"; + /// Destination array is not compatible with objects within '{0}'. + internal const string @Collection_BadDestArray = "Collection_BadDestArray"; + /// Input array is not a valid rank. + internal const string @Collection_BadRank = "Collection_BadRank"; + /// Cannot add instance of type '{1}' to a collection of type '{0}'. Only items of type '{2}' are allowed. + internal const string @Collection_BadType = "Collection_BadType"; + /// Cannot pass multidimensional array to the CopyTo method on a collection. + internal const string @Collection_CopyTo_ArrayCannotBeMultidimensional = "Collection_CopyTo_ArrayCannotBeMultidimensional"; + /// '{0}' parameter value is equal to or greater than the length of the '{1}' parameter value. + internal const string @Collection_CopyTo_IndexGreaterThanOrEqualToArrayLength = "Collection_CopyTo_IndexGreaterThanOrEqualToArrayLength"; + /// The number of elements in this collection is greater than the available space from '{0}' to the end of destination '{1}'. + internal const string @Collection_CopyTo_NumberOfElementsExceedsArrayLength = "Collection_CopyTo_NumberOfElementsExceedsArrayLength"; + /// Cannot add null to the collection. + internal const string @Collection_NoNull = "Collection_NoNull"; + /// File is too large to be a valid ColorContext. + internal const string @ColorContext_FileTooLarge = "ColorContext_FileTooLarge"; + /// Color context must be sRGB or scRGB for this operation. + internal const string @Color_ColorContextNotsRGB_or_scRGB = "Color_ColorContextNotsRGB_or_scRGB"; + /// Color context types mismatch. + internal const string @Color_ColorContextTypeMismatch = "Color_ColorContextTypeMismatch"; + /// Color context dimensions mismatch. + internal const string @Color_DimensionMismatch = "Color_DimensionMismatch"; + /// Color context is null. + internal const string @Color_NullColorContext = "Color_NullColorContext"; + /// The property '{0}' cannot be changed. The '{1}' class has been sealed. + internal const string @CompatibilityPreferencesSealed = "CompatibilityPreferencesSealed"; + /// Typography properties are not valid. + internal const string @CompileFeatureSet_InvalidTypographyProperties = "CompileFeatureSet_InvalidTypographyProperties"; + /// Invalid value for {0} attribute. + internal const string @CompositeFontAttributeValue1 = "CompositeFontAttributeValue1"; + /// Invalid value for {0} attribute: {1} + internal const string @CompositeFontAttributeValue2 = "CompositeFontAttributeValue2"; + /// Unicode range is not valid. + internal const string @CompositeFontInvalidUnicodeRange = "CompositeFontInvalidUnicodeRange"; + /// Missing required attribute '{0}'. + internal const string @CompositeFontMissingAttribute = "CompositeFontMissingAttribute"; + /// Missing required element '{0}'. + internal const string @CompositeFontMissingElement = "CompositeFontMissingElement"; + /// The composite font contains significant whitespace where none is expected. + internal const string @CompositeFontSignificantWhitespace = "CompositeFontSignificantWhitespace"; + /// '{0}' attribute in XML namespace '{1}' not recognized. Note that attribute names are case sensitive. + internal const string @CompositeFontUnknownAttribute = "CompositeFontUnknownAttribute"; + /// '{0}' element in XML namespace '{1}' not recognized. Note that element names are case sensitive. + internal const string @CompositeFontUnknownElement = "CompositeFontUnknownElement"; + /// A FontFamily cannot have more than one FamilyTypeface with the same Style, Weight, and Stretch. + internal const string @CompositeFont_DuplicateTypeface = "CompositeFont_DuplicateTypeface"; + /// The FontFamily cannot hold any more FamilyMaps. + internal const string @CompositeFont_TooManyFamilyMaps = "CompositeFont_TooManyFamilyMaps"; + /// The root Visual of a VisualTarget cannot have a parent. + internal const string @CompositionTarget_RootVisual_HasParent = "CompositionTarget_RootVisual_HasParent"; + /// Possible constructor recursion detected. + internal const string @ConstructorRecursion = "ConstructorRecursion"; + /// Shift+F10;Apps + internal const string @ContextMenuKeyDisplayString = "ContextMenuKeyDisplayString"; + /// Context Menu + internal const string @ContextMenuText = "ContextMenuText"; + /// Cannot convert from type. + internal const string @Converter_ConvertFromNotSupported = "Converter_ConvertFromNotSupported"; + /// Cannot convert to type. + internal const string @Converter_ConvertToNotSupported = "Converter_ConvertToNotSupported"; + /// Ctrl+C;Ctrl+Insert + internal const string @CopyKeyDisplayString = "CopyKeyDisplayString"; + /// Copy + internal const string @CopyText = "CopyText"; + /// + internal const string @CorrectionListKey = "CorrectionListKey"; + /// + internal const string @CorrectionListKeyDisplayString = "CorrectionListKeyDisplayString"; + /// Correction List + internal const string @CorrectionListText = "CorrectionListText"; + /// Count must be less than or equal to remaining number of bits in stream. + internal const string @CountOfBitsGreatThanRemainingBits = "CountOfBitsGreatThanRemainingBits"; + /// Count must be less than or equal to bits per byte and greater than zero. + internal const string @CountOfBitsOutOfRange = "CountOfBitsOutOfRange"; + /// Text formatting engine cannot format breakpoints due to error: '{0}'. + internal const string @CreateBreaksFailure = "CreateBreaksFailure"; + /// Text formatting engine cannot create text formatting context due to error: '{0}'. + internal const string @CreateContextFailure = "CreateContextFailure"; + /// Text formatting engine cannot format a line of text due to error: '{0}'. + internal const string @CreateLineFailure = "CreateLineFailure"; + /// Text formatting engine cannot format a paragraph cache due to error: '{0}'. + internal const string @CreateParaBreakingSessionFailure = "CreateParaBreakingSessionFailure"; + /// Current dispatcher cannot be found. + internal const string @CurrentDispatcherNotFound = "CurrentDispatcherNotFound"; + /// Failed to load cursor from the stream. + internal const string @Cursor_InvalidStream = "Cursor_InvalidStream"; + /// Failed to load cursor file '{0}'. + internal const string @Cursor_LoadImageFailure = "Cursor_LoadImageFailure"; + /// '{0}' has unsupported extension for cursor. + internal const string @Cursor_UnsupportedFormat = "Cursor_UnsupportedFormat"; + /// Ctrl+X;Shift+Delete + internal const string @CutKeyDisplayString = "CutKeyDisplayString"; + /// Cut + internal const string @CutText = "CutText"; + /// An antialiased back buffer requires a IDirect3DDevice9Ex device. + internal const string @D3DImage_AARequires9Ex = "D3DImage_AARequires9Ex"; + /// Back buffer's device is not valid. + internal const string @D3DImage_InvalidDevice = "D3DImage_InvalidDevice"; + /// Back buffer's pool does not meet the requirements for the resource type. + internal const string @D3DImage_InvalidPool = "D3DImage_InvalidPool"; + /// Back buffer's usage does not meet the requirements for the resource type. + internal const string @D3DImage_InvalidUsage = "D3DImage_InvalidUsage"; + /// Cannot call this method without a back buffer. + internal const string @D3DImage_MustHaveBackBuffer = "D3DImage_MustHaveBackBuffer"; + /// Back buffer's size is too large. + internal const string @D3DImage_SurfaceTooBig = "D3DImage_SurfaceTooBig"; + /// Cannot SetData on a frozen OLE data object. + internal const string @DataObject_CannotSetDataOnAFozenOLEDataDbject = "DataObject_CannotSetDataOnAFozenOLEDataDbject"; + /// '{0}' data format is not present on DataObject. + internal const string @DataObject_DataFormatNotPresentOnDataObject = "DataObject_DataFormatNotPresentOnDataObject"; + /// Data object must have at least one format. + internal const string @DataObject_DataObjectMustHaveAtLeastOneFormat = "DataObject_DataObjectMustHaveAtLeastOneFormat"; + /// Empty string is not a valid value for parameter 'format'. + internal const string @DataObject_EmptyFormatNotAllowed = "DataObject_EmptyFormatNotAllowed"; + /// '{0}' file drop path is not valid. + internal const string @DataObject_FileDropListHasInvalidFileDropPath = "DataObject_FileDropListHasInvalidFileDropPath"; + /// '{0}' must contain at least one file drop path. + internal const string @DataObject_FileDropListIsEmpty = "DataObject_FileDropListIsEmpty"; + /// '{0}' dwDirection parameter value is not supported. + internal const string @DataObject_NotImplementedEnumFormatEtc = "DataObject_NotImplementedEnumFormatEtc"; + /// Decompression of packet data failed. + internal const string @DecompressPacketDataFailed = "DecompressPacketDataFailed"; + /// Decompression of property data failed. + internal const string @DecompressPropertyFailed = "DecompressPropertyFailed"; + /// + internal const string @DecreaseZoomKey = "DecreaseZoomKey"; + /// + internal const string @DecreaseZoomKeyDisplayString = "DecreaseZoomKeyDisplayString"; + /// Decrease Zoom + internal const string @DecreaseZoomText = "DecreaseZoomText"; + /// Del + internal const string @DeleteKeyDisplayString = "DeleteKeyDisplayString"; + /// Delete + internal const string @DeleteText = "DeleteText"; + /// Cannot find a part of the path '{0}'. + internal const string @DirectoryNotFoundExceptionWithFileName = "DirectoryNotFoundExceptionWithFileName"; + /// '{0}' DragAction is not valid. + internal const string @DragDrop_DragActionInvalid = "DragDrop_DragActionInvalid"; + /// '{0}' DragDropEffects is not valid. + internal const string @DragDrop_DragDropEffectsInvalid = "DragDrop_DragDropEffectsInvalid"; + /// This Pop operation has no corresponding Push to remove from the stack because the stack depth of the DrawingContext is zero. + internal const string @DrawingContext_TooManyPops = "DrawingContext_TooManyPops"; + /// This object has an outstanding DrawingContext. The DrawingContext must be Closed or Disposed before making Open or Append calls. + internal const string @DrawingGroup_AlreadyOpen = "DrawingGroup_AlreadyOpen"; + /// Cannot append to a frozen DrawingGroup.Children collection. + internal const string @DrawingGroup_CannotAppendToFrozenCollection = "DrawingGroup_CannotAppendToFrozenCollection"; + /// Cannot append to a null DrawingGroup.Children collection. + internal const string @DrawingGroup_CannotAppendToNullCollection = "DrawingGroup_CannotAppendToNullCollection"; + /// Duplicate ApplicationGesture values are not allowed. + internal const string @DuplicateApplicationGestureFound = "DuplicateApplicationGestureFound"; + /// RoutedEvent Name '{0}' for OwnerType '{1}' already used. + internal const string @DuplicateEventName = "DuplicateEventName"; + /// Duplicate Stroke in StrokeCollectionChangedEventArgs.Added. + internal const string @DuplicateStrokeAdded = "DuplicateStrokeAdded"; + /// A pixel shader using Pixel Shader Model 2.0 cannot be set because registers only available in Pixel Shader Model 3.0 are being used. + internal const string @Effect_20ShaderUsing30Registers = "Effect_20ShaderUsing30Registers"; + /// BitmapEffect and Effect cannot be combined on a Visual. + internal const string @Effect_CombinedLegacyAndNew = "Effect_CombinedLegacyAndNew"; + /// Cannot call BitmapEffect.GetOutput directly with a ContextInputSource. Provide a valid BitmapSource. + internal const string @Effect_No_ContextInputSource = "Effect_No_ContextInputSource"; + /// There is no input set. + internal const string @Effect_No_InputSource = "Effect_No_InputSource"; + /// '{0}' PixelFormat is not supported for this operation. + internal const string @Effect_PixelFormat = "Effect_PixelFormat"; + /// An error occurred on the render thread with a user-supplied shader. + internal const string @Effect_RenderThreadError = "Effect_RenderThreadError"; + /// Pixel Shader Model 2.0 requires floating point constants to be in registers [0-31]. + internal const string @Effect_Shader20ConstantRegisterLimit = "Effect_Shader20ConstantRegisterLimit"; + /// Pixel Shader Model 2.0 requires sampler constants to be in registers [0-3]. + internal const string @Effect_Shader20SamplerRegisterLimit = "Effect_Shader20SamplerRegisterLimit"; + /// Pixel Shader Model 3.0 requires boolean constants to be in registers [0-15]. + internal const string @Effect_Shader30BoolConstantRegisterLimit = "Effect_Shader30BoolConstantRegisterLimit"; + /// Pixel Shader Model 3.0 requires floating point constants to be in registers [0-223]. + internal const string @Effect_Shader30FloatConstantRegisterLimit = "Effect_Shader30FloatConstantRegisterLimit"; + /// Pixel Shader Model 3.0 requires integer constants to be in registers [0-15]. + internal const string @Effect_Shader30IntConstantRegisterLimit = "Effect_Shader30IntConstantRegisterLimit"; + /// Pixel Shader Model 3.0 requires sampler constants to be in registers [0-7]. + internal const string @Effect_Shader30SamplerRegisterLimit = "Effect_Shader30SamplerRegisterLimit"; + /// Shader bytecode must be an integral number of 4-byte words. + internal const string @Effect_ShaderBytecodeSize = "Effect_ShaderBytecodeSize"; + /// No shader bytecode present. Must either set UriSource or call SetStreamSource. + internal const string @Effect_ShaderBytecodeSource = "Effect_ShaderBytecodeSource"; + /// Shader constant of type '{0}' is not allowed. + internal const string @Effect_ShaderConstantType = "Effect_ShaderConstantType"; + /// Padding must be non-negative. + internal const string @Effect_ShaderEffectPadding = "Effect_ShaderEffectPadding"; + /// PixelShader must be set on ShaderEffect. + internal const string @Effect_ShaderPixelShaderSet = "Effect_ShaderPixelShaderSet"; + /// Pixel shader sampler must be Effect.ImplicitInput, or an instance of BitmapCacheBrush, VisualBrush, or ImageBrush. + internal const string @Effect_ShaderSamplerType = "Effect_ShaderSamplerType"; + /// PixelShader only accepts seekable streams. + internal const string @Effect_ShaderSeekableStream = "Effect_ShaderSeekableStream"; + /// Uri must be a file or pack Uri. + internal const string @Effect_SourceUriMustBeFileOrPack = "Effect_SourceUriMustBeFileOrPack"; + /// Empty arrays are not a valid argument value. + internal const string @EmptyArray = "EmptyArray"; + /// The array cannot be empty. + internal const string @EmptyArrayNotAllowedAsArgument = "EmptyArrayNotAllowedAsArgument"; + /// No data to load. + internal const string @EmptyDataToLoad = "EmptyDataToLoad"; + /// Collection cannot be empty. + internal const string @EmptyScToReplace = "EmptyScToReplace"; + /// The replacement StrokeCollection cannot be empty. + internal const string @EmptyScToReplaceWith = "EmptyScToReplaceWith"; + /// EndHitTesting has already been called on the IncrementalHitTester. + internal const string @EndHitTestingCalled = "EndHitTestingCalled"; + /// End of stream reached. + internal const string @EndOfStreamReached = "EndOfStreamReached"; + /// The enumerator is not valid because the collection changed. + internal const string @Enumerator_CollectionChanged = "Enumerator_CollectionChanged"; + /// The enumerator has not been started. + internal const string @Enumerator_NotStarted = "Enumerator_NotStarted"; + /// The enumerator has reached the end of the collection. + internal const string @Enumerator_ReachedEnd = "Enumerator_ReachedEnd"; + /// No current object to return. + internal const string @Enumerator_VerifyContext = "Enumerator_VerifyContext"; + /// Text formatting engine cannot enumerate contents in a line due to error: '{0}'. + internal const string @EnumLineFailure = "EnumLineFailure"; + /// '{0}' enumeration value is not valid. + internal const string @Enum_Invalid = "Enum_Invalid"; + /// ExtendedProperty is already part of the ExtendedPropertyCollection. + internal const string @EPExists = "EPExists"; + /// The GUID is not part of the ExtendedPropertyCollection. + internal const string @EPGuidNotFound = "EPGuidNotFound"; + /// Property not set. + internal const string @EPNotFound = "EPNotFound"; + /// Event arguments must be non-null. + internal const string @EventArgIsNull = "EventArgIsNull"; + /// Shift+Down + internal const string @ExtendSelectionDownKeyDisplayString = "ExtendSelectionDownKeyDisplayString"; + /// Extend Selection Down + internal const string @ExtendSelectionDownText = "ExtendSelectionDownText"; + /// Shift+Left + internal const string @ExtendSelectionLeftKeyDisplayString = "ExtendSelectionLeftKeyDisplayString"; + /// Extend Selection Left + internal const string @ExtendSelectionLeftText = "ExtendSelectionLeftText"; + /// Shift+Right + internal const string @ExtendSelectionRightKeyDisplayString = "ExtendSelectionRightKeyDisplayString"; + /// Extend Selection Right + internal const string @ExtendSelectionRightText = "ExtendSelectionRightText"; + /// Shift+Up + internal const string @ExtendSelectionUpKeyDisplayString = "ExtendSelectionUpKeyDisplayString"; + /// Extend Selection Up + internal const string @ExtendSelectionUpText = "ExtendSelectionUpText"; + /// Font face index must be greater than or equal to zero. + internal const string @FaceIndexMustBePositiveOrZero = "FaceIndexMustBePositiveOrZero"; + /// Nonzero font face index values are valid only for TrueType collections (.ttc). + internal const string @FaceIndexValidOnlyForTTC = "FaceIndexValidOnlyForTTC"; + /// Cannot load system composite fonts. Location not found. + internal const string @FamilyCollection_CannotFindCompositeFontsLocation = "FamilyCollection_CannotFindCompositeFontsLocation"; + /// Cannot add FamilyMap because Target property is not set. + internal const string @FamilyMap_TargetNotSet = "FamilyMap_TargetNotSet"; + /// Ctrl+I + internal const string @FavoritesKeyDisplayString = "FavoritesKeyDisplayString"; + /// Favorites + internal const string @FavoritesText = "FavoritesText"; + /// Input file or data stream does not conform to the expected file format specification. + internal const string @FileFormatException = "FileFormatException"; + /// '{0}' file does not conform to the expected file format specification. + internal const string @FileFormatExceptionWithFileName = "FileFormatExceptionWithFileName"; + /// Cannot find file '{0}'. + internal const string @FileNotFoundExceptionWithFileName = "FileNotFoundExceptionWithFileName"; + /// Ctrl+F + internal const string @FindKeyDisplayString = "FindKeyDisplayString"; + /// Find + internal const string @FindText = "FindText"; + /// + internal const string @FirstPageKey = "FirstPageKey"; + /// + internal const string @FirstPageKeyDisplayString = "FirstPageKeyDisplayString"; + /// First Page + internal const string @FirstPageText = "FirstPageText"; + /// Unrecognized float type in BAML file. + internal const string @FloatUnknownBamlType = "FloatUnknownBamlType"; + /// Stream does not support Flush. + internal const string @FlushNotSupported = "FlushNotSupported"; + /// A named FontFamily object cannot be modified. + internal const string @FontFamily_ReadOnly = "FontFamily_ReadOnly"; + /// Specified value of type '{0}' must have IsFrozen set to false to modify. + internal const string @Freezable_CantBeFrozen = "Freezable_CantBeFrozen"; + /// Clone of an instance of type '{0}' is null or not an instance of '{0}'. + internal const string @Freezable_CloneInvalidType = "Freezable_CloneInvalidType"; + /// Cannot change FreezableCollection during a CollectionChanged event. + internal const string @Freezable_Reentrant = "Freezable_Reentrant"; + /// Unknown/unexpected change event + internal const string @Freezable_UnexpectedChange = "Freezable_UnexpectedChange"; + /// The transform is not defined for the point. + internal const string @GeneralTransform_TransformFailed = "GeneralTransform_TransformFailed"; + /// The object passed to '{0}' is not a valid type. + internal const string @General_BadType = "General_BadType"; + /// Expected object of type '{0}'. + internal const string @General_Expected_Type = "General_Expected_Type"; + /// The object is marked 'Read Only'. + internal const string @General_ObjectIsReadOnly = "General_ObjectIsReadOnly"; + /// Arithmetic error found while trying to perform this operation. + internal const string @Geometry_BadNumber = "Geometry_BadNumber"; + /// No gesture recognizer is available on the system. + internal const string @GestureRecognizerNotAvailable = "GestureRecognizerNotAvailable"; + /// Text formatting engine cannot retrieve penalty module handle due to error: '{0}'. + internal const string @GetPenaltyModuleHandleFailure = "GetPenaltyModuleHandleFailure"; + /// Cannot get response for web request to '{0}'. + internal const string @GetResponseFailed = "GetResponseFailed"; + /// Values for advanceWidths and glyphOffsets constitute too large of a GlyphRun. The area of its bounding box, measured in renderingEmSize squares, is '{0}' but it cannot exceed '{1}'. + internal const string @GlyphAreaTooBig = "GlyphAreaTooBig"; + /// advanceWidths and glyphOffsets constitute coordinate too large for glyph at index '{0}'. For renderingEmSize '{1}' the values cannot exceed '{2}'. + internal const string @GlyphCoordinateTooBig = "GlyphCoordinateTooBig"; + /// '{0}' glyph index is not valid for the specified font. + internal const string @GlyphIndexOutOfRange = "GlyphIndexOutOfRange"; + /// Glyph typeface URI does not point to a previously recorded glyph typeface. + internal const string @GlyphTypefaceNotRecorded = "GlyphTypefaceNotRecorded"; + /// + internal const string @GoToPageKey = "GoToPageKey"; + /// + internal const string @GoToPageKeyDisplayString = "GoToPageKeyDisplayString"; + /// Go To Page + internal const string @GoToPageText = "GoToPageText"; + /// Handler type is mismatched. + internal const string @HandlerTypeIllegal = "HandlerTypeIllegal"; + /// F1 + internal const string @HelpKeyDisplayString = "HelpKeyDisplayString"; + /// Help + internal const string @HelpText = "HelpText"; + /// '{0}' HitTestParameters are not supported on '{1}'. + internal const string @HitTest_Invalid = "HitTest_Invalid"; + /// Hit testing with a singular MatrixCamera is not supported. + internal const string @HitTest_Singular = "HitTest_Singular"; + /// Cannot access a disposed HwndSource. + internal const string @HwndSourceDisposed = "HwndSourceDisposed"; + /// Due to protocol mismatch hardware support is not available. + internal const string @HwndTarget_HardwareNotSupportDueToProtocolMismatch = "HwndTarget_HardwareNotSupportDueToProtocolMismatch"; + /// The specified handle is not a valid window handle. + internal const string @HwndTarget_InvalidWindowHandle = "HwndTarget_InvalidWindowHandle"; + /// The specified window does not belong to the current process. + internal const string @HwndTarget_InvalidWindowProcess = "HwndTarget_InvalidWindowProcess"; + /// The specified window does not belong to the current thread. + internal const string @HwndTarget_InvalidWindowThread = "HwndTarget_InvalidWindowThread"; + /// Another HwndTarget is associated with this window. + internal const string @HwndTarget_WindowAlreadyHasContent = "HwndTarget_WindowAlreadyHasContent"; + /// Cannot animate the '{0}' property on '{1}' because the object is sealed or frozen. + internal const string @IAnimatable_CantAnimateSealedDO = "IAnimatable_CantAnimateSealedDO"; + /// Alpha threshold must be from 0 through 100. + internal const string @Image_AlphaThresholdOutOfRange = "Image_AlphaThresholdOutOfRange"; + /// The bitmap specified does not have the correct dimensions. + internal const string @Image_BadDimensions = "Image_BadDimensions"; + /// The image has corrupted metadata header. + internal const string @Image_BadMetadataHeader = "Image_BadMetadataHeader"; + /// '{0}' not a valid pixel format. + internal const string @Image_BadPixelFormat = "Image_BadPixelFormat"; + /// The stream is corrupted. + internal const string @Image_BadStreamData = "Image_BadStreamData"; + /// DLL version not correct. + internal const string @Image_BadVersion = "Image_BadVersion"; + /// Unable to create temporary file for download. + internal const string @Image_CannotCreateTempFile = "Image_CannotCreateTempFile"; + /// The Image passed to the ImageVisualManager cannot be frozen. + internal const string @Image_CantBeFrozen = "Image_CantBeFrozen"; + /// The codec cannot use the type of stream provided. + internal const string @Image_CantDealWithStream = "Image_CantDealWithStream"; + /// The codec cannot use the type of URI provided. + internal const string @Image_CantDealWithUri = "Image_CantDealWithUri"; + /// Codec added more than once. + internal const string @Image_CodecPresent = "Image_CodecPresent"; + /// Color context is not valid. + internal const string @Image_ColorContextInvalid = "Image_ColorContextInvalid"; + /// Color transform is not valid. + internal const string @Image_ColorTransformInvalid = "Image_ColorTransformInvalid"; + /// No imaging component suitable to complete this operation was found. + internal const string @Image_ComponentNotFound = "Image_ComponentNotFound"; + /// The mime type registered with the system does not match the mime type of the file. + internal const string @Image_ContentTypeDoesNotMatchDecoder = "Image_ContentTypeDoesNotMatchDecoder"; + /// The image decoder cannot decode the image. The image might be corrupted. + internal const string @Image_DecoderError = "Image_DecoderError"; + /// The system display state is not valid. + internal const string @Image_DisplayStateInvalid = "Image_DisplayStateInvalid"; + /// Duplicate copies of metadata present. + internal const string @Image_DuplicateMetadataPresent = "Image_DuplicateMetadataPresent"; + /// The designated BitmapEncoder does not support ColorContexts. + internal const string @Image_EncoderNoColorContext = "Image_EncoderNoColorContext"; + /// The designated BitmapEncoder does not support global metadata. + internal const string @Image_EncoderNoGlobalMetadata = "Image_EncoderNoGlobalMetadata"; + /// The designated BitmapEncoder does not support global thumbnails. + internal const string @Image_EncoderNoGlobalThumbnail = "Image_EncoderNoGlobalThumbnail"; + /// The designated BitmapEncoder does not support previews. + internal const string @Image_EncoderNoPreview = "Image_EncoderNoPreview"; + /// Cannot call EndInit without a matching BeginInit call. + internal const string @Image_EndInitWithoutBeginInit = "Image_EndInitWithoutBeginInit"; + /// The image is missing a frame. + internal const string @Image_FrameMissing = "Image_FrameMissing"; + /// This class does not support cloning. + internal const string @Image_FreezableCloneNotAllowed = "Image_FreezableCloneNotAllowed"; + /// Empty GUID is not valid for '{0}'. + internal const string @Image_GuidEmpty = "Image_GuidEmpty"; + /// The image cannot be decoded. The image header might be corrupted. + internal const string @Image_HeaderError = "Image_HeaderError"; + /// Must specify a palette when using an indexed pixel format. + internal const string @Image_IndexedPixelFormatRequiresPalette = "Image_IndexedPixelFormatRequiresPalette"; + /// Already in an initializing state. + internal const string @Image_InInitialize = "Image_InInitialize"; + /// BitmapImage initialization is not complete. Call the EndInit method to complete the initialization. + internal const string @Image_InitializationIncomplete = "Image_InitializationIncomplete"; + /// InPlaceBitmapMetadataWriter cannot be copied. + internal const string @Image_InplaceMetadataNoCopy = "Image_InplaceMetadataNoCopy"; + /// Buffer size is not sufficient. + internal const string @Image_InsufficientBuffer = "Image_InsufficientBuffer"; + /// Buffer not large enough to copy memory. + internal const string @Image_InsufficientBufferSize = "Image_InsufficientBufferSize"; + /// An error occurred. + internal const string @Image_InternalError = "Image_InternalError"; + /// Cannot match the type of this array to a pixel format. + internal const string @Image_InvalidArrayForPixel = "Image_InvalidArrayForPixel"; + /// Bitmap color context is not valid. + internal const string @Image_InvalidColorContext = "Image_InvalidColorContext"; + /// Character is not valid in metadata query request. + internal const string @Image_InvalidQueryCharacter = "Image_InvalidQueryCharacter"; + /// Metadata query request is not valid. + internal const string @Image_InvalidQueryRequest = "Image_InvalidQueryRequest"; + /// The lock count cannot exceed UInt32.MaxValue. + internal const string @Image_LockCountLimit = "Image_LockCountLimit"; + /// BitmapMetadata initialization incomplete. + internal const string @Image_MetadataInitializationIncomplete = "Image_MetadataInitializationIncomplete"; + /// The bitmap metadata is not compatible with this container format. + internal const string @Image_MetadataNotCompatible = "Image_MetadataNotCompatible"; + /// BitmapMetadata is not available on BitmapImage. + internal const string @Image_MetadataNotSupported = "Image_MetadataNotSupported"; + /// Bitmap metadata cannot be changed. + internal const string @Image_MetadataReadOnly = "Image_MetadataReadOnly"; + /// Cannot add any more top-level metadata blocks. + internal const string @Image_MetadataSizeFixed = "Image_MetadataSizeFixed"; + /// Cannot call this method while the image is unlocked. + internal const string @Image_MustBeLocked = "Image_MustBeLocked"; + /// Property '{0}' or property '{1}' must be set. + internal const string @Image_NeitherArgument = "Image_NeitherArgument"; + /// '{0}' property is not set. + internal const string @Image_NoArgument = "Image_NoArgument"; + /// No codec found that can decode the specified file. + internal const string @Image_NoCodecsFound = "Image_NoCodecsFound"; + /// Image does not contain any frames. + internal const string @Image_NoDecodeFrames = "Image_NoDecodeFrames"; + /// Cannot save an image with no frames. + internal const string @Image_NoFrames = "Image_NoFrames"; + /// The specified image does not contain a palette. + internal const string @Image_NoPalette = "Image_NoPalette"; + /// No information was found about this pixel format. + internal const string @Image_NoPixelFormatFound = "Image_NoPixelFormatFound"; + /// Bitmap does not contain thumbnail. + internal const string @Image_NoThumbnail = "Image_NoThumbnail"; + /// BitmapImage has not been initialized. Call the BeginInit method, set the appropriate properties, and then call the EndInit method. + internal const string @Image_NotInitialized = "Image_NotInitialized"; + /// Cannot set the initializing state more than once. + internal const string @Image_OnlyOneInit = "Image_OnlyOneInit"; + /// Cannot call Save on an Encoder more than once. + internal const string @Image_OnlyOneSave = "Image_OnlyOneSave"; + /// Transform must be a combination of scales, flips, and 90 degree rotations. + internal const string @Image_OnlyOrthogonal = "Image_OnlyOrthogonal"; + /// In place editing of bitmap metadata is not allowed because the original source is not writable. + internal const string @Image_OriginalStreamReadOnly = "Image_OriginalStreamReadOnly"; + /// The image data generated an overflow during processing. + internal const string @Image_Overflow = "Image_Overflow"; + /// The number of colors in the palette is larger than the maximum allowed by the supplied pixel format. + internal const string @Image_PaletteColorsDoNotMatchFormat = "Image_PaletteColorsDoNotMatchFormat"; + /// Must use a fixed palette type. '{0}' not supported here. + internal const string @Image_PaletteFixedType = "Image_PaletteFixedType"; + /// Cannot create a palette with less than 1 color or more than 256 colors. + internal const string @Image_PaletteZeroColors = "Image_PaletteZeroColors"; + /// Property cannot be found. + internal const string @Image_PropertyNotFound = "Image_PropertyNotFound"; + /// This codec does not support the specified property. + internal const string @Image_PropertyNotSupported = "Image_PropertyNotSupported"; + /// Property is corrupted. + internal const string @Image_PropertySize = "Image_PropertySize"; + /// Unexpected property type or value. + internal const string @Image_PropertyUnexpectedType = "Image_PropertyUnexpectedType"; + /// The metadata query is valid only at the root of the metadata hierarchy. + internal const string @Image_RequestOnlyValidAtMetadataRoot = "Image_RequestOnlyValidAtMetadataRoot"; + /// Cannot set this property outside a BeginInit/EndInit block. + internal const string @Image_SetPropertyOutsideBeginEndInit = "Image_SetPropertyOutsideBeginEndInit"; + /// Cannot invert singular matrix. + internal const string @Image_SingularMatrix = "Image_SingularMatrix"; + /// Bad Rotation parameter. Only Rotate0, Rotate90, Rotate180, and Rotate270 are supported. + internal const string @Image_SizeOptionsAngle = "Image_SizeOptionsAngle"; + /// The image dimensions are out of the range supported by this codec. + internal const string @Image_SizeOutOfRange = "Image_SizeOutOfRange"; + /// Metadata stream is not available for this operation. + internal const string @Image_StreamNotAvailable = "Image_StreamNotAvailable"; + /// Cannot read from the stream. + internal const string @Image_StreamRead = "Image_StreamRead"; + /// Cannot write to the stream. + internal const string @Image_StreamWrite = "Image_StreamWrite"; + /// The bitmap has too many scanlines for the specified encoder. + internal const string @Image_TooManyScanlines = "Image_TooManyScanlines"; + /// Commit unsuccessful because too much metadata changed. + internal const string @Image_TooMuchMetadata = "Image_TooMuchMetadata"; + /// Unexpected type of metadata. + internal const string @Image_UnexpectedMetadataType = "Image_UnexpectedMetadataType"; + /// The image format is unrecognized. + internal const string @Image_UnknownFormat = "Image_UnknownFormat"; + /// Operation not supported. + internal const string @Image_UnsupportedOperation = "Image_UnsupportedOperation"; + /// Pixel format not supported. + internal const string @Image_UnsupportedPixelFormat = "Image_UnsupportedPixelFormat"; + /// Operation caused an invalid state. + internal const string @Image_WrongState = "Image_WrongState"; + /// The StylusPointDescriptions are incompatible. Use the StylusPointDescription.GetCommonDescription method to find a common StylusPointDescription and then call StylusPointCollection.Reformat to return a compatible StylusPointCollection. + internal const string @IncompatibleStylusPointDescriptions = "IncompatibleStylusPointDescriptions"; + /// + internal const string @IncreaseZoomKey = "IncreaseZoomKey"; + /// + internal const string @IncreaseZoomKeyDisplayString = "IncreaseZoomKeyDisplayString"; + /// Increase Zoom + internal const string @IncreaseZoomText = "IncreaseZoomText"; + /// The object is already being initialized. + internal const string @InInitialization = "InInitialization"; + /// The operation fails because the object is not fully initialized. + internal const string @InitializationIncomplete = "InitializationIncomplete"; + /// Cannot initialize compressor. + internal const string @InitializingCompressorFailed = "InitializingCompressorFailed"; + /// InnerRequest not available for preloaded packages. + internal const string @InnerRequestNotAllowed = "InnerRequestNotAllowed"; + /// Gesture accepts only objects of type '{0}'. + internal const string @InputBinding_ExpectedInputGesture = "InputBinding_ExpectedInputGesture"; + /// InputLanguageManager is not ready to change the current input languages. + internal const string @InputLanguageManager_NotReadyToChangeCurrentLanguage = "InputLanguageManager_NotReadyToChangeCurrentLanguage"; + /// '{0}' is not a valid ImeConversionMode. + internal const string @InputMethod_InvalidConversionMode = "InputMethod_InvalidConversionMode"; + /// '{0}' is not a valid ImeSentenceMode. + internal const string @InputMethod_InvalidSentenceMode = "InputMethod_InvalidSentenceMode"; + /// The InputProviderSite has already been disposed. + internal const string @InputProviderSiteDisposed = "InputProviderSiteDisposed"; + /// '{0}' is not a valid InputScopeName. + internal const string @InputScope_InvalidInputScopeName = "InputScope_InvalidInputScopeName"; + /// Integer collection size cannot be negative. + internal const string @IntegerCollectionLengthLessThanZero = "IntegerCollectionLengthLessThanZero"; + /// An absolute URI in a font family name must have file:// scheme. + internal const string @InvalidAbsoluteUriInFontFamilyName = "InvalidAbsoluteUriInFontFamilyName"; + /// The additional data passed in does not match what is expected based on the StylusPointDescription. + internal const string @InvalidAdditionalDataForStylusPoint = "InvalidAdditionalDataForStylusPoint"; + /// Maximum buffer length must be within actual buffer length. + internal const string @InvalidBufferLength = "InvalidBufferLength"; + /// Byte ranges are not valid in '{0}'. + internal const string @InvalidByteRanges = "InvalidByteRanges"; + /// '{0}' cursor type is not valid. + internal const string @InvalidCursorType = "InvalidCursorType"; + /// The specified data is invalid, see inner exception for details. + internal const string @InvalidDataInISF = "InvalidDataInISF"; + /// Property data must be a non-reference variant compatible type. + internal const string @InvalidDataTypeForExtendedProperty = "InvalidDataTypeForExtendedProperty"; + /// The value is out of range. + internal const string @InvalidDiameter = "InvalidDiameter"; + /// Height must be greater than or equal to DrawingAttributes.MinHeight and less than or equal to DrawingAttribute.MaxHeight. + internal const string @InvalidDrawingAttributesHeight = "InvalidDrawingAttributesHeight"; + /// Width must be greater than or equal to DrawingAttributes.MinWidth and less than or equal to DrawingAttribute.MaxWidth. + internal const string @InvalidDrawingAttributesWidth = "InvalidDrawingAttributesWidth"; + /// Extended property data type is not valid. + internal const string @InvalidEpInIsf = "InvalidEpInIsf"; + /// The event handle is not usable. + internal const string @InvalidEventHandle = "InvalidEventHandle"; + /// GUID cannot be empty. + internal const string @InvalidGuid = "InvalidGuid"; + /// The specified GUID represents a button, so isButton must be true. + internal const string @InvalidIsButtonForId = "InvalidIsButtonForId"; + /// The specified GUID does not represent a button, so isButton must be false. + internal const string @InvalidIsButtonForId2 = "InvalidIsButtonForId2"; + /// Infinity member value is not valid in Matrix. + internal const string @InvalidMatrixContainsInfinity = "InvalidMatrixContainsInfinity"; + /// NaN is not a valid value for Matrix member. + internal const string @InvalidMatrixContainsNaN = "InvalidMatrixContainsNaN"; + /// StylusPointPropertyInfos that are buttons must have a minimum of 0 and a maximum of 1. + internal const string @InvalidMinMaxForButton = "InvalidMinMaxForButton"; + /// The part name does not correspond to its content type. + internal const string @InvalidPartName = "InvalidPartName"; + /// PermissionState value '{0}' is not valid for this Permission. + internal const string @InvalidPermissionStateValue = "InvalidPermissionStateValue"; + /// Permission type is not valid. Expected '{0}'. + internal const string @InvalidPermissionType = "InvalidPermissionType"; + /// Pressure must be a value between 0 and 1. + internal const string @InvalidPressureValue = "InvalidPressureValue"; + /// The stroke being removed does not exist in the current collection. + internal const string @InvalidRemovedStroke = "InvalidRemovedStroke"; + /// The stroke being replaced does not exist in the current collection. + internal const string @InvalidReplacedStroke = "InvalidReplacedStroke"; + /// HTTP byte range downloader can support only HTTP or HTTPS schemes. + internal const string @InvalidScheme = "InvalidScheme"; + /// '{0}' must be a relative URI for site of origin. + internal const string @InvalidSiteOfOriginUri = "InvalidSiteOfOriginUri"; + /// The specified size is less than the information decoded in the ISF stream. + internal const string @InvalidSizeSpecified = "InvalidSizeSpecified"; + /// Stream is not valid. + internal const string @InvalidStream = "InvalidStream"; + /// Translation is not valid. + internal const string @InvalidSttValue = "InvalidSttValue"; + /// StylusPointCollection cannot be empty when attached to a Stroke. + internal const string @InvalidStylusPointCollectionZeroCount = "InvalidStylusPointCollectionZeroCount"; + /// The specified collection cannot be empty. + internal const string @InvalidStylusPointConstructionZeroLengthCollection = "InvalidStylusPointConstructionZeroLengthCollection"; + /// StylusPointDescription must contain at least X, Y and NormalPressure in that order. + internal const string @InvalidStylusPointDescription = "InvalidStylusPointDescription"; + /// When constructing a StylusPointDescription, any StylusPointPropertyInfos that represent buttons must be placed at the end of the collection. + internal const string @InvalidStylusPointDescriptionButtonsMustBeLast = "InvalidStylusPointDescriptionButtonsMustBeLast"; + /// StylusPointDescription cannot contain duplicate StylusPointPropertyInfos. + internal const string @InvalidStylusPointDescriptionDuplicatesFound = "InvalidStylusPointDescriptionDuplicatesFound"; + /// The specified StylusPointDescription must be a subset. + internal const string @InvalidStylusPointDescriptionSubset = "InvalidStylusPointDescriptionSubset"; + /// StylusPointDescription supports no more than 31 buttons. + internal const string @InvalidStylusPointDescriptionTooManyButtons = "InvalidStylusPointDescriptionTooManyButtons"; + /// The StylusPoint does not support the specified StylusPointProperty. + internal const string @InvalidStylusPointProperty = "InvalidStylusPointProperty"; + /// Resolution must be at least 0.0f. + internal const string @InvalidStylusPointPropertyInfoResolution = "InvalidStylusPointPropertyInfoResolution"; + /// Value cannot be Double.NaN. + internal const string @InvalidStylusPointXYNaN = "InvalidStylusPointXYNaN"; + /// Cannot have empty name of a temporary file. + internal const string @InvalidTempFileName = "InvalidTempFileName"; + /// The requested TextDecorationCollection string is not valid: '{0}'. + internal const string @InvalidTextDecorationCollectionString = "InvalidTextDecorationCollectionString"; + /// Invalid value '{0}' for type '{1}'. + internal const string @InvalidValueOfType = "InvalidValueOfType"; + /// Value must be of type '{0}'. + internal const string @InvalidValueType = "InvalidValueType"; + /// Value must be of type '{0}' or '{1}'. + internal const string @InvalidValueType1 = "InvalidValueType1"; + /// '{0}' is not a valid type for IInputElement. UIElement or ContentElement expected. + internal const string @Invalid_IInputElement = "Invalid_IInputElement"; + /// The length of the ISF data must be greater than zero. + internal const string @Invalid_isfData_Length = "Invalid_isfData_Length"; + /// The URI specified is invalid. + internal const string @Invalid_URI = "Invalid_URI"; + /// A read or write operation references a location outside the bounds of the buffer provided. + internal const string @IOBufferOverflow = "IOBufferOverflow"; + /// I/O error when opening file '{0}'. + internal const string @IOExceptionWithFileName = "IOExceptionWithFileName"; + /// InkSerializedFormat operation failed. + internal const string @IsfOperationFailed = "IsfOperationFailed"; + /// The specified keyboard sink is already owned by a site. + internal const string @KeyboardSinkAlreadyOwned = "KeyboardSinkAlreadyOwned"; + /// The specified keyboard sink must be a UIElement. + internal const string @KeyboardSinkMustBeAnElement = "KeyboardSinkMustBeAnElement"; + /// The specified keyboard sink is not a child of this source. + internal const string @KeyboardSinkNotAChild = "KeyboardSinkNotAChild"; + /// '{0}+{1}' key and modifier combination is not supported for KeyGesture. + internal const string @KeyGesture_Invalid = "KeyGesture_Invalid"; + /// + internal const string @LastPageKey = "LastPageKey"; + /// + internal const string @LastPageKeyDisplayString = "LastPageKeyDisplayString"; + /// Last Page + internal const string @LastPageText = "LastPageText"; + /// Layout recursion reached allowed limit to avoid stack overflow: '{0}'. Either the tree contains a loop or is too deep. + internal const string @LayoutManager_DeepRecursion = "LayoutManager_DeepRecursion"; + /// An unrecognized ManipulationMode flag was encountered. + internal const string @Manipulation_InvalidManipulationMode = "Manipulation_InvalidManipulationMode"; + /// Manipulation is not active on the specified element. + internal const string @Manipulation_ManipulationNotActive = "Manipulation_ManipulationNotActive"; + /// IsManipulationEnabled is not set to true on the specified element. + internal const string @Manipulation_ManipulationNotEnabled = "Manipulation_ManipulationNotEnabled"; + /// Cannot invert the matrix, because the matrix is not invertible. + internal const string @Matrix3D_NotInvertible = "Matrix3D_NotInvertible"; + /// The specified Matrix must be invertible. + internal const string @MatrixNotInvertible = "MatrixNotInvertible"; + /// + internal const string @MediaBoostBassKey = "MediaBoostBassKey"; + /// + internal const string @MediaBoostBassKeyDisplayString = "MediaBoostBassKeyDisplayString"; + /// Boost Bass + internal const string @MediaBoostBassText = "MediaBoostBassText"; + /// + internal const string @MediaChannelDownKey = "MediaChannelDownKey"; + /// + internal const string @MediaChannelDownKeyDisplayString = "MediaChannelDownKeyDisplayString"; + /// Channel Down + internal const string @MediaChannelDownText = "MediaChannelDownText"; + /// + internal const string @MediaChannelUpKey = "MediaChannelUpKey"; + /// + internal const string @MediaChannelUpKeyDisplayString = "MediaChannelUpKeyDisplayString"; + /// Channel Up + internal const string @MediaChannelUpText = "MediaChannelUpText"; + /// Cannot call this API during the OnRender callback. During OnRender, only drawing operations that draw the content of the Visual can be performed. + internal const string @MediaContext_APINotAllowed = "MediaContext_APINotAllowed"; + /// An infinite loop appears to have resulted from cross-dependent views. + internal const string @MediaContext_InfiniteLayoutLoop = "MediaContext_InfiniteLayoutLoop"; + /// An infinite loop appears to have resulted from repeatedly invalidating the TimeManager during the Layout/Render process. + internal const string @MediaContext_InfiniteTickLoop = "MediaContext_InfiniteTickLoop"; + /// Invalid user-specified pixel shader. Register a PixelShader.InvalidPixelShaderEncountered event handler to avoid this exception being raised. + internal const string @MediaContext_NoBadShaderHandler = "MediaContext_NoBadShaderHandler"; + /// Out of video memory. + internal const string @MediaContext_OutOfVideoMemory = "MediaContext_OutOfVideoMemory"; + /// An unspecified error occurred on the render thread. + internal const string @MediaContext_RenderThreadError = "MediaContext_RenderThreadError"; + /// + internal const string @MediaDecreaseBassKey = "MediaDecreaseBassKey"; + /// + internal const string @MediaDecreaseBassKeyDisplayString = "MediaDecreaseBassKeyDisplayString"; + /// Decrease Bass + internal const string @MediaDecreaseBassText = "MediaDecreaseBassText"; + /// + internal const string @MediaDecreaseMicrophoneVolumeKey = "MediaDecreaseMicrophoneVolumeKey"; + /// + internal const string @MediaDecreaseMicrophoneVolumeKeyDisplayString = "MediaDecreaseMicrophoneVolumeKeyDisplayString"; + /// Decrease Microphone Volume + internal const string @MediaDecreaseMicrophoneVolumeText = "MediaDecreaseMicrophoneVolumeText"; + /// + internal const string @MediaDecreaseTrebleKey = "MediaDecreaseTrebleKey"; + /// + internal const string @MediaDecreaseTrebleKeyDisplayString = "MediaDecreaseTrebleKeyDisplayString"; + /// Decrease Treble + internal const string @MediaDecreaseTrebleText = "MediaDecreaseTrebleText"; + /// + internal const string @MediaDecreaseVolumeKey = "MediaDecreaseVolumeKey"; + /// + internal const string @MediaDecreaseVolumeKeyDisplayString = "MediaDecreaseVolumeKeyDisplayString"; + /// Decrease Volume + internal const string @MediaDecreaseVolumeText = "MediaDecreaseVolumeText"; + /// + internal const string @MediaFastForwardKey = "MediaFastForwardKey"; + /// + internal const string @MediaFastForwardKeyDisplayString = "MediaFastForwardKeyDisplayString"; + /// Fast Forward + internal const string @MediaFastForwardText = "MediaFastForwardText"; + /// + internal const string @MediaIncreaseBassKey = "MediaIncreaseBassKey"; + /// + internal const string @MediaIncreaseBassKeyDisplayString = "MediaIncreaseBassKeyDisplayString"; + /// Increase Bass + internal const string @MediaIncreaseBassText = "MediaIncreaseBassText"; + /// + internal const string @MediaIncreaseMicrophoneVolumeKey = "MediaIncreaseMicrophoneVolumeKey"; + /// + internal const string @MediaIncreaseMicrophoneVolumeKeyDisplayString = "MediaIncreaseMicrophoneVolumeKeyDisplayString"; + /// Increase Microphone Volume + internal const string @MediaIncreaseMicrophoneVolumeText = "MediaIncreaseMicrophoneVolumeText"; + /// + internal const string @MediaIncreaseTrebleKey = "MediaIncreaseTrebleKey"; + /// + internal const string @MediaIncreaseTrebleKeyDisplayString = "MediaIncreaseTrebleKeyDisplayString"; + /// Increase Treble + internal const string @MediaIncreaseTrebleText = "MediaIncreaseTrebleText"; + /// + internal const string @MediaIncreaseVolumeKey = "MediaIncreaseVolumeKey"; + /// + internal const string @MediaIncreaseVolumeKeyDisplayString = "MediaIncreaseVolumeKeyDisplayString"; + /// Increase Volume + internal const string @MediaIncreaseVolumeText = "MediaIncreaseVolumeText"; + /// + internal const string @MediaMuteMicrophoneVolumeKey = "MediaMuteMicrophoneVolumeKey"; + /// + internal const string @MediaMuteMicrophoneVolumeKeyDisplayString = "MediaMuteMicrophoneVolumeKeyDisplayString"; + /// Mute Microphone Volume + internal const string @MediaMuteMicrophoneVolumeText = "MediaMuteMicrophoneVolumeText"; + /// + internal const string @MediaMuteVolumeKey = "MediaMuteVolumeKey"; + /// + internal const string @MediaMuteVolumeKeyDisplayString = "MediaMuteVolumeKeyDisplayString"; + /// Mute Volume + internal const string @MediaMuteVolumeText = "MediaMuteVolumeText"; + /// + internal const string @MediaNextTrackKey = "MediaNextTrackKey"; + /// + internal const string @MediaNextTrackKeyDisplayString = "MediaNextTrackKeyDisplayString"; + /// Next Track + internal const string @MediaNextTrackText = "MediaNextTrackText"; + /// + internal const string @MediaPauseKey = "MediaPauseKey"; + /// + internal const string @MediaPauseKeyDisplayString = "MediaPauseKeyDisplayString"; + /// Pause + internal const string @MediaPauseText = "MediaPauseText"; + /// + internal const string @MediaPlayKey = "MediaPlayKey"; + /// + internal const string @MediaPlayKeyDisplayString = "MediaPlayKeyDisplayString"; + /// Play + internal const string @MediaPlayText = "MediaPlayText"; + /// + internal const string @MediaPreviousTrackKey = "MediaPreviousTrackKey"; + /// + internal const string @MediaPreviousTrackKeyDisplayString = "MediaPreviousTrackKeyDisplayString"; + /// Previous Track + internal const string @MediaPreviousTrackText = "MediaPreviousTrackText"; + /// + internal const string @MediaRecordKey = "MediaRecordKey"; + /// + internal const string @MediaRecordKeyDisplayString = "MediaRecordKeyDisplayString"; + /// Record + internal const string @MediaRecordText = "MediaRecordText"; + /// + internal const string @MediaRewindKey = "MediaRewindKey"; + /// + internal const string @MediaRewindKeyDisplayString = "MediaRewindKeyDisplayString"; + /// Rewind + internal const string @MediaRewindText = "MediaRewindText"; + /// + internal const string @MediaSelectKey = "MediaSelectKey"; + /// + internal const string @MediaSelectKeyDisplayString = "MediaSelectKeyDisplayString"; + /// Select + internal const string @MediaSelectText = "MediaSelectText"; + /// + internal const string @MediaStopKey = "MediaStopKey"; + /// + internal const string @MediaStopKeyDisplayString = "MediaStopKeyDisplayString"; + /// Stop + internal const string @MediaStopText = "MediaStopText"; + /// This API was accessed with arguments from the wrong context. + internal const string @MediaSystem_ApiInvalidContext = "MediaSystem_ApiInvalidContext"; + /// Received an out of order connect or disconnect message. + internal const string @MediaSystem_OutOfOrderConnectOrDisconnect = "MediaSystem_OutOfOrderConnectOrDisconnect"; + /// + internal const string @MediaToggleMicrophoneOnOffKey = "MediaToggleMicrophoneOnOffKey"; + /// + internal const string @MediaToggleMicrophoneOnOffKeyDisplayString = "MediaToggleMicrophoneOnOffKeyDisplayString"; + /// Toggle Microphone OnOff + internal const string @MediaToggleMicrophoneOnOffText = "MediaToggleMicrophoneOnOffText"; + /// + internal const string @MediaTogglePlayPauseKey = "MediaTogglePlayPauseKey"; + /// + internal const string @MediaTogglePlayPauseKeyDisplayString = "MediaTogglePlayPauseKeyDisplayString"; + /// Toggle Play Pause + internal const string @MediaTogglePlayPauseText = "MediaTogglePlayPauseText"; + /// Media file download failed. + internal const string @Media_DownloadFailed = "Media_DownloadFailed"; + /// Installed codecs do not support the media file format. + internal const string @Media_FileFormatNotSupported = "Media_FileFormatNotSupported"; + /// Cannot find the media file. + internal const string @Media_FileNotFound = "Media_FileNotFound"; + /// Display driver must support video acceleration for video or audio playback. + internal const string @Media_HardwareVideoAccelerationNotAvailable = "Media_HardwareVideoAccelerationNotAvailable"; + /// There are insufficient video resources available for video or audio playback. + internal const string @Media_InsufficientVideoResources = "Media_InsufficientVideoResources"; + /// Value does not fall within the expected range. + internal const string @Media_InvalidArgument = "Media_InvalidArgument"; + /// Windows Media Player version 10 or later is required. + internal const string @Media_InvalidWmpVersion = "Media_InvalidWmpVersion"; + /// Access was denied on the media file. + internal const string @Media_LogonFailure = "Media_LogonFailure"; + /// Cannot perform this operation while a clock is assigned to the media player. + internal const string @Media_NotAllowedWhileTimingEngineInControl = "Media_NotAllowedWhileTimingEngineInControl"; + /// Only site-of-origin pack URIs are supported for media. + internal const string @Media_PackURIsAreNotSupported = "Media_PackURIsAreNotSupported"; + /// No operations are valid on a closed media player except open and close. + internal const string @Media_PlayerIsClosed = "Media_PlayerIsClosed"; + /// Unrecognized playlist file format. + internal const string @Media_PlaylistFormatNotSupported = "Media_PlaylistFormatNotSupported"; + /// Cannot access the stream after it is closed. + internal const string @Media_StreamClosed = "Media_StreamClosed"; + /// Accessed an uninitialized media resource. + internal const string @Media_UninitializedResource = "Media_UninitializedResource"; + /// Channel type is not recognized. + internal const string @Media_UnknownChannelType = "Media_UnknownChannelType"; + /// An unknown media error occurred. + internal const string @Media_UnknownMediaExecption = "Media_UnknownMediaExecption"; + /// Must specify URI. + internal const string @Media_UriNotSpecified = "Media_UriNotSpecified"; + /// The '{0}' method cannot be called at this time. + internal const string @MethodCallNotAllowed = "MethodCallNotAllowed"; + /// Mismatched versions of PresentationCore.dll, Milcore.dll, WindowsCodecs.dll, or D3d9.dll. Check that these DLLs come from the same source. + internal const string @MilErr_UnsupportedVersion = "MilErr_UnsupportedVersion"; + /// RoutedEvent in RoutedEventArgs and EventRoute are mismatched. + internal const string @Mismatched_RoutedEvent = "Mismatched_RoutedEvent"; + /// Down + internal const string @MoveDownKeyDisplayString = "MoveDownKeyDisplayString"; + /// Move Down + internal const string @MoveDownText = "MoveDownText"; + /// Ctrl+Left + internal const string @MoveFocusBackKeyDisplayString = "MoveFocusBackKeyDisplayString"; + /// Move Focus Back + internal const string @MoveFocusBackText = "MoveFocusBackText"; + /// Ctrl+Down + internal const string @MoveFocusDownKeyDisplayString = "MoveFocusDownKeyDisplayString"; + /// Move Focus Down + internal const string @MoveFocusDownText = "MoveFocusDownText"; + /// Ctrl+Right + internal const string @MoveFocusForwardKeyDisplayString = "MoveFocusForwardKeyDisplayString"; + /// Move Focus Forward + internal const string @MoveFocusForwardText = "MoveFocusForwardText"; + /// Ctrl+PageDown + internal const string @MoveFocusPageDownKeyDisplayString = "MoveFocusPageDownKeyDisplayString"; + /// Move Focus Page Down + internal const string @MoveFocusPageDownText = "MoveFocusPageDownText"; + /// Ctrl+PageUp + internal const string @MoveFocusPageUpKeyDisplayString = "MoveFocusPageUpKeyDisplayString"; + /// Move Focus Page Up + internal const string @MoveFocusPageUpText = "MoveFocusPageUpText"; + /// Ctrl+Up + internal const string @MoveFocusUpKeyDisplayString = "MoveFocusUpKeyDisplayString"; + /// Move Focus Up + internal const string @MoveFocusUpText = "MoveFocusUpText"; + /// Left + internal const string @MoveLeftKeyDisplayString = "MoveLeftKeyDisplayString"; + /// Move Left + internal const string @MoveLeftText = "MoveLeftText"; + /// Right + internal const string @MoveRightKeyDisplayString = "MoveRightKeyDisplayString"; + /// Move Right + internal const string @MoveRightText = "MoveRightText"; + /// End + internal const string @MoveToEndKeyDisplayString = "MoveToEndKeyDisplayString"; + /// Move To End + internal const string @MoveToEndText = "MoveToEndText"; + /// Home + internal const string @MoveToHomeKeyDisplayString = "MoveToHomeKeyDisplayString"; + /// Move To Home + internal const string @MoveToHomeText = "MoveToHomeText"; + /// PageDown + internal const string @MoveToPageDownKeyDisplayString = "MoveToPageDownKeyDisplayString"; + /// Move To Page Down + internal const string @MoveToPageDownText = "MoveToPageDownText"; + /// PageUp + internal const string @MoveToPageUpKeyDisplayString = "MoveToPageUpKeyDisplayString"; + /// Move To Page Up + internal const string @MoveToPageUpText = "MoveToPageUpText"; + /// Up + internal const string @MoveUpKeyDisplayString = "MoveUpKeyDisplayString"; + /// Move Up + internal const string @MoveUpText = "MoveUpText"; + /// Cannot have more than one '{0}' instance in the same AppDomain. + internal const string @MultiSingleton = "MultiSingleton"; + /// + internal const string @NavigateJournalKey = "NavigateJournalKey"; + /// + internal const string @NavigateJournalKeyDisplayString = "NavigateJournalKeyDisplayString"; + /// Navigate Journal + internal const string @NavigateJournalText = "NavigateJournalText"; + /// Ctrl+N + internal const string @NewKeyDisplayString = "NewKeyDisplayString"; + /// New + internal const string @NewText = "NewText"; + /// + internal const string @NextPageKey = "NextPageKey"; + /// + internal const string @NextPageKeyDisplayString = "NextPageKeyDisplayString"; + /// Next Page + internal const string @NextPageText = "NextPageText"; + /// Text formatting engine encountered a non-CLS exception. + internal const string @NonCLSException = "NonCLSException"; + /// Unsupported Uri syntax. Method expects a relative Uri or a pack://application:,,,/ form of absolute Uri. + internal const string @NonPackAppAbsoluteUriNotAllowed = "NonPackAppAbsoluteUriNotAllowed"; + /// Text content is not allowed on this element. Cannot add the text '{0}'. + internal const string @NonWhiteSpaceInAddText = "NonWhiteSpaceInAddText"; + /// Not a Command + internal const string @NotACommandText = "NotACommandText"; + /// The package URI is not allowed in the package store. + internal const string @NotAllowedPackageUri = "NotAllowedPackageUri"; + /// Only PreProcessInput and PostProcessInput events can access InputManager staging area. + internal const string @NotAllowedToAccessStagingArea = "NotAllowedToAccessStagingArea"; + /// The object is not being initialized. + internal const string @NotInInitialization = "NotInInitialization"; + /// '{0}' parameter cannot be null unless '{1}' is an absolute URI. + internal const string @NullBaseUriParam = "NullBaseUriParam"; + /// Hwnd of zero is not valid. + internal const string @NullHwnd = "NullHwnd"; + /// Offset must be non-negative. + internal const string @OffsetNegative = "OffsetNegative"; + /// OleRegisterDragDrop failed with return code '{0}' and window handle '{1}'. + internal const string @OleRegisterDragDropFailure = "OleRegisterDragDropFailure"; + /// OleRevokeDragDrop failed with return code '{0}' and window handle '{1}'. + internal const string @OleRevokeDragDropFailure = "OleRevokeDragDropFailure"; + /// OleInitialize failed for '{0}'. + internal const string @OleServicesContext_oleInitializeFailure = "OleServicesContext_oleInitializeFailure"; + /// Current thread must be set to single thread apartment (STA) mode before OLE calls can be made. + internal const string @OleServicesContext_ThreadMustBeSTA = "OleServicesContext_ThreadMustBeSTA"; + /// Keyboard processing can only process keyboard messages. + internal const string @OnlyAcceptsKeyMessages = "OnlyAcceptsKeyMessages"; + /// The object is already initialized and cannot be initialized again. + internal const string @OnlyOneInitialization = "OnlyOneInitialization"; + /// Ctrl+O + internal const string @OpenKeyDisplayString = "OpenKeyDisplayString"; + /// Open + internal const string @OpenText = "OpenText"; + /// Paragraph must be allowed to wrap in total-fit formatting. + internal const string @OptimalParagraphMustWrap = "OptimalParagraphMustWrap"; + /// A package with the same URI is already in the package store. + internal const string @PackageAlreadyExists = "PackageAlreadyExists"; + /// Cache policy is not valid. + internal const string @PackWebRequestCachePolicyIllegal = "PackWebRequestCachePolicyIllegal"; + /// Specified ContentPosition is not valid for this element. + internal const string @PaginatorMissingContentPosition = "PaginatorMissingContentPosition"; + /// Page number cannot be negative. + internal const string @PaginatorNegativePageNumber = "PaginatorNegativePageNumber"; + /// The parameter value cannot be greater than '{0}'. + internal const string @ParameterCannotBeGreaterThan = "ParameterCannotBeGreaterThan"; + /// The parameter value cannot be less than '{0}'. + internal const string @ParameterCannotBeLessThan = "ParameterCannotBeLessThan"; + /// Parameter must be greater than or equal to zero. + internal const string @ParameterCannotBeNegative = "ParameterCannotBeNegative"; + /// The parameter value must be between '{0}' and '{1}'. + internal const string @ParameterMustBeBetween = "ParameterMustBeBetween"; + /// The parameter value must be greater than zero. + internal const string @ParameterMustBeGreaterThanZero = "ParameterMustBeGreaterThanZero"; + /// The parameter value must be finite. + internal const string @ParameterValueCannotBeInfinity = "ParameterValueCannotBeInfinity"; + /// The parameter value must be a number. + internal const string @ParameterValueCannotBeNaN = "ParameterValueCannotBeNaN"; + /// '{0}' parameter value cannot be negative. + internal const string @ParameterValueCannotBeNegative = "ParameterValueCannotBeNegative"; + /// '{0}' parameter value must be greater than zero. + internal const string @ParameterValueMustBeGreaterThanZero = "ParameterValueMustBeGreaterThanZero"; + /// Token is not valid. + internal const string @Parsers_IllegalToken = "Parsers_IllegalToken"; + /// Token is not valid because it is more than 250 characters. + internal const string @Parsers_IllegalToken_250_Chars = "Parsers_IllegalToken_250_Chars"; + /// Incorrect form '{0}' found parsing '{1}' string. + internal const string @Parser_BadForm = "Parser_BadForm"; + /// Empty string not allowed. + internal const string @Parser_Empty = "Parser_Empty"; + /// Unexpected token '{0}' encountered at position '{1}'. + internal const string @Parser_UnexpectedToken = "Parser_UnexpectedToken"; + /// Ctrl+V;Shift+Insert + internal const string @PasteKeyDisplayString = "PasteKeyDisplayString"; + /// Paste + internal const string @PasteText = "PasteText"; + /// Internal error in newly produced path figures. + internal const string @PathGeometry_InternalReadBackError = "PathGeometry_InternalReadBackError"; + /// '{0}' file name is longer than the system-defined maximum length. + internal const string @PathTooLongExceptionWithFileName = "PathTooLongExceptionWithFileName"; + /// Cannot access a disposed pen service. + internal const string @Penservice_Disposed = "Penservice_Disposed"; + /// Unexpected size of packet from pen service. + internal const string @PenService_InvalidPacketData = "PenService_InvalidPacketData"; + /// The window is already registered for stylus input. + internal const string @PenService_WindowAlreadyRegistered = "PenService_WindowAlreadyRegistered"; + /// The window is not registered for stylus input. + internal const string @PenService_WindowNotRegistered = "PenService_WindowNotRegistered"; + /// + internal const string @PreviousPageKey = "PreviousPageKey"; + /// + internal const string @PreviousPageKeyDisplayString = "PreviousPageKeyDisplayString"; + /// Previous Page + internal const string @PreviousPageText = "PreviousPageText"; + /// Ctrl+P + internal const string @PrintKeyDisplayString = "PrintKeyDisplayString"; + /// Ctrl+F2 + internal const string @PrintPreviewKeyDisplayString = "PrintPreviewKeyDisplayString"; + /// Print Preview + internal const string @PrintPreviewText = "PrintPreviewText"; + /// Print + internal const string @PrintText = "PrintText"; + /// F4 + internal const string @PropertiesKeyDisplayString = "PropertiesKeyDisplayString"; + /// Properties + internal const string @PropertiesText = "PropertiesText"; + /// '{0}' property value must be greater than or equal to zero. + internal const string @PropertyCannotBeNegative = "PropertyCannotBeNegative"; + /// '{0}' property value must be greater than zero. + internal const string @PropertyMustBeGreaterThanZero = "PropertyMustBeGreaterThanZero"; + /// '{0}' property of the '{1}' class must be less than or equal to '{2}'. + internal const string @PropertyOfClassCannotBeGreaterThan = "PropertyOfClassCannotBeGreaterThan"; + /// '{0}' property of the '{1}' class cannot be null. + internal const string @PropertyOfClassCannotBeNull = "PropertyOfClassCannotBeNull"; + /// '{0}' property of the '{1}' class must be greater than zero. + internal const string @PropertyOfClassMustBeGreaterThanZero = "PropertyOfClassMustBeGreaterThanZero"; + /// '{0}' property value cannot be NaN. + internal const string @PropertyValueCannotBeNaN = "PropertyValueCannotBeNaN"; + /// Zero axis of rotation specified. + internal const string @Quaternion_ZeroAxisSpecified = "Quaternion_ZeroAxisSpecified"; + /// Text formatting engine cannot query text information due to error: '{0}'. + internal const string @QueryLineFailure = "QueryLineFailure"; + /// Count of bytes to read cannot be negative. + internal const string @ReadCountNegative = "ReadCountNegative"; + /// Operation not supported on a read-only InputGestureCollection. + internal const string @ReadOnlyInputGesturesCollection = "ReadOnlyInputGesturesCollection"; + /// Cannot call the method. + internal const string @Rect3D_CannotCallMethod = "Rect3D_CannotCallMethod"; + /// Cannot modify this property on the Empty Rect3D. + internal const string @Rect3D_CannotModifyEmptyRect = "Rect3D_CannotModifyEmptyRect"; + /// Rectangle cannot be empty. + internal const string @Rect_Empty = "Rect_Empty"; + /// Ctrl+Y + internal const string @RedoKeyDisplayString = "RedoKeyDisplayString"; + /// Redo + internal const string @RedoText = "RedoText"; + /// The visual tree has been changed during a '{0}' event. + internal const string @ReentrantVisualTreeChangeError = "ReentrantVisualTreeChangeError"; + /// WARNING. The visual tree has been changed during a '{0}' event. This is not supported in a production application. Be sure to correct this before shipping the application. + internal const string @ReentrantVisualTreeChangeWarning = "ReentrantVisualTreeChangeWarning"; + /// F5 + internal const string @RefreshKeyDisplayString = "RefreshKeyDisplayString"; + /// Refresh + internal const string @RefreshText = "RefreshText"; + /// Text formatting engine cannot release penalty resource due to error: '{0}'. + internal const string @RelievePenaltyResourceFailure = "RelievePenaltyResourceFailure"; + /// Ctrl+H + internal const string @ReplaceKeyDisplayString = "ReplaceKeyDisplayString"; + /// Replace + internal const string @ReplaceText = "ReplaceText"; + /// The operation is not allowed after the first request is made. + internal const string @RequestAlreadyStarted = "RequestAlreadyStarted"; + /// The calling thread must be STA, because many UI components require this. + internal const string @RequiresSTA = "RequiresSTA"; + /// Current CachePolicy is CacheOnly but the requested resource does not exist in the cache. + internal const string @ResourceNotFoundUnderCacheOnlyPolicy = "ResourceNotFoundUnderCacheOnlyPolicy"; + /// Every RoutedEventArgs must have a non-null RoutedEvent associated with it. + internal const string @RoutedEventArgsMustHaveRoutedEvent = "RoutedEventArgsMustHaveRoutedEvent"; + /// Cannot change the RoutedEvent property while the RoutedEvent is being routed. + internal const string @RoutedEventCannotChangeWhileRouting = "RoutedEventCannotChangeWhileRouting"; + /// Save As + internal const string @SaveAsText = "SaveAsText"; + /// Ctrl+S + internal const string @SaveKeyDisplayString = "SaveKeyDisplayString"; + /// Save + internal const string @SaveText = "SaveText"; + /// The Strokes have changed. + internal const string @SCDataChanged = "SCDataChanged"; + /// Path of erasing stroke cannot be null. + internal const string @SCErasePath = "SCErasePath"; + /// Erasing Shape cannot be null. + internal const string @SCEraseShape = "SCEraseShape"; + /// Cannot resolve current inner request URI schema. Bypass cache only for resolvable schema types such as http, ftp, or file. + internal const string @SchemaInvalidForTransport = "SchemaInvalidForTransport"; + /// The scope must be a UIElement or ContentElement. + internal const string @ScopeMustBeUIElementOrContent = "ScopeMustBeUIElementOrContent"; + /// + internal const string @ScrollByLineKey = "ScrollByLineKey"; + /// + internal const string @ScrollByLineKeyDisplayString = "ScrollByLineKeyDisplayString"; + /// Scroll By Line + internal const string @ScrollByLineText = "ScrollByLineText"; + /// PageDown + internal const string @ScrollPageDownKeyDisplayString = "ScrollPageDownKeyDisplayString"; + /// Scroll Page Down + internal const string @ScrollPageDownText = "ScrollPageDownText"; + /// + internal const string @ScrollPageLeftKey = "ScrollPageLeftKey"; + /// + internal const string @ScrollPageLeftKeyDisplayString = "ScrollPageLeftKeyDisplayString"; + /// Scroll Page Left + internal const string @ScrollPageLeftText = "ScrollPageLeftText"; + /// + internal const string @ScrollPageRightKey = "ScrollPageRightKey"; + /// + internal const string @ScrollPageRightKeyDisplayString = "ScrollPageRightKeyDisplayString"; + /// Scroll Page Right + internal const string @ScrollPageRightText = "ScrollPageRightText"; + /// PageUp + internal const string @ScrollPageUpKeyDisplayString = "ScrollPageUpKeyDisplayString"; + /// Scroll Page Up + internal const string @ScrollPageUpText = "ScrollPageUpText"; + /// F3 + internal const string @SearchKey = "SearchKey"; + /// F3 + internal const string @SearchKeyDisplayString = "SearchKeyDisplayString"; + /// Search + internal const string @SearchText = "SearchText"; + /// Cannot set SandboxExternalContent to true in partial trust. + internal const string @SecurityExceptionForSettingSandboxExternalToTrue = "SecurityExceptionForSettingSandboxExternalToTrue"; + /// Cannot set seek pointer to a negative position. + internal const string @SeekNegative = "SeekNegative"; + /// SeekOrigin value is not valid. + internal const string @SeekOriginInvalid = "SeekOriginInvalid"; + /// Ctrl+A + internal const string @SelectAllKeyDisplayString = "SelectAllKeyDisplayString"; + /// Select All + internal const string @SelectAllText = "SelectAllText"; + /// Shift+End + internal const string @SelectToEndKeyDisplayString = "SelectToEndKeyDisplayString"; + /// Select To End + internal const string @SelectToEndText = "SelectToEndText"; + /// Shift+Home + internal const string @SelectToHomeKeyDisplayString = "SelectToHomeKeyDisplayString"; + /// Select To Home + internal const string @SelectToHomeText = "SelectToHomeText"; + /// Shift+PageDown + internal const string @SelectToPageDownKeyDisplayString = "SelectToPageDownKeyDisplayString"; + /// Select To PageDown + internal const string @SelectToPageDownText = "SelectToPageDownText"; + /// Shift+PageUp + internal const string @SelectToPageUpKeyDisplayString = "SelectToPageUpKeyDisplayString"; + /// Select To PageUp + internal const string @SelectToPageUpText = "SelectToPageUpText"; + /// Text formatting engine cannot set breaking conditions due to error: '{0}'. + internal const string @SetBreakingFailure = "SetBreakingFailure"; + /// Text formatting engine cannot set document context due to error: '{0}'. + internal const string @SetDocFailure = "SetDocFailure"; + /// The target element cannot receive focus. + internal const string @SetFocusFailed = "SetFocusFailed"; + /// Stream does not support SetLength. + internal const string @SetLengthNotSupported = "SetLengthNotSupported"; + /// Text formatting engine cannot set tab stop due to error: '{0}'. + internal const string @SetTabsFailure = "SetTabsFailure"; + /// Sideways right to left text is not supported. + internal const string @SidewaysRTLTextIsNotSupported = "SidewaysRTLTextIsNotSupported"; + /// Cannot modify this property on the Empty Size3D. + internal const string @Size3D_CannotModifyEmptySize = "Size3D_CannotModifyEmptySize"; + /// Cannot set a negative dimension. + internal const string @Size3D_DimensionCannotBeNegative = "Size3D_DimensionCannotBeNegative"; + /// Must set Source in RoutedEventArgs before building event route or invoking handlers. + internal const string @SourceNotSet = "SourceNotSet"; + /// The CultureInfo object used for number substitution must be a specific culture, not a neutral culture or InvariantCulture. + internal const string @SpecificNumberCultureRequired = "SpecificNumberCultureRequired"; + /// Esc + internal const string @StopKeyDisplayString = "StopKeyDisplayString"; + /// Stop + internal const string @StopText = "StopText"; + /// BeginFigure must be called before this API. + internal const string @StreamGeometry_NeedBeginFigure = "StreamGeometry_NeedBeginFigure"; + /// Parameter cannot be a zero-length string. + internal const string @StringEmpty = "StringEmpty"; + /// Maximum number of strokes is two. + internal const string @StrokeCollectionCountTooBig = "StrokeCollectionCountTooBig"; + /// The specified StrokeCollection is read-only. + internal const string @StrokeCollectionIsReadOnly = "StrokeCollectionIsReadOnly"; + /// A duplicate stroke cannot be added to StrokeCollection. + internal const string @StrokeIsDuplicated = "StrokeIsDuplicated"; + /// The strokes being replaced must exist contiguously in the current StrokeCollection. + internal const string @StrokesNotContiguously = "StrokesNotContiguously"; + /// NotifyWhenProcessed can be called only during OnStylusDown, OnStylusMove, or OnStylusUp. + internal const string @Stylus_CanOnlyCallForDownMoveOrUp = "Stylus_CanOnlyCallForDownMoveOrUp"; + /// No current object to return. + internal const string @Stylus_EnumeratorFailure = "Stylus_EnumeratorFailure"; + /// '{0}' is not a valid index in the collection. + internal const string @Stylus_IndexOutOfRange = "Stylus_IndexOutOfRange"; + /// '{0}' must be greater than or equal to '{1}'. + internal const string @Stylus_InvalidMax = "Stylus_InvalidMax"; + /// Matrix is not invertible. + internal const string @Stylus_MatrixNotInvertable = "Stylus_MatrixNotInvertable"; + /// Stylus or Mouse must be in the down state when calling Reset. + internal const string @Stylus_MustBeDownToCallReset = "Stylus_MustBeDownToCallReset"; + /// Stylus input encountered an error. + internal const string @Stylus_PenContextFailure = "Stylus_PenContextFailure"; + /// '{0}' already exists in the collection. + internal const string @Stylus_PlugInIsDuplicated = "Stylus_PlugInIsDuplicated"; + /// '{0}' must be non-null. + internal const string @Stylus_PlugInIsNull = "Stylus_PlugInIsNull"; + /// '{0}' does not exist in the collection. + internal const string @Stylus_PlugInNotExist = "Stylus_PlugInNotExist"; + /// Count of points must be greater than zero. + internal const string @Stylus_StylusPointsCantBeEmpty = "Stylus_StylusPointsCantBeEmpty"; + /// Text breakpoint was previously disposed. + internal const string @TextBreakpointHasBeenDisposed = "TextBreakpointHasBeenDisposed"; + /// '{0}' does not have a valid InputManager. + internal const string @TextCompositionManager_NoInputManager = "TextCompositionManager_NoInputManager"; + /// '{0}' has already finished. + internal const string @TextCompositionManager_TextCompositionHasDone = "TextCompositionManager_TextCompositionHasDone"; + /// '{0}' has already started. + internal const string @TextCompositionManager_TextCompositionHasStarted = "TextCompositionManager_TextCompositionHasStarted"; + /// '{0}' has not yet started. + internal const string @TextCompositionManager_TextCompositionNotStarted = "TextCompositionManager_TextCompositionNotStarted"; + /// Result text cannot be null. + internal const string @TextComposition_NullResultText = "TextComposition_NullResultText"; + /// Cannot reenter Text formatting engine during optimal paragraph formatting. + internal const string @TextFormatterReentranceProhibited = "TextFormatterReentranceProhibited"; + /// Text line was previously disposed. + internal const string @TextLineHasBeenDisposed = "TextLineHasBeenDisposed"; + /// The return value of TextEmbeddedObject.Format contains an out-of-range value for the Width property. + internal const string @TextObjectMetrics_WidthOutOfRange = "TextObjectMetrics_WidthOutOfRange"; + /// Text penalty module was previously disposed. + internal const string @TextPenaltyModuleHasBeenDisposed = "TextPenaltyModuleHasBeenDisposed"; + /// '{0}' parameter value is not a valid child element of the text provider. + internal const string @TextProvider_InvalidChild = "TextProvider_InvalidChild"; + /// '{0}' parameter value is not a valid ITextRangeProvider. + internal const string @TextRangeProvider_InvalidRangeProvider = "TextRangeProvider_InvalidRangeProvider"; + /// The Properties member of this text run cannot be null. + internal const string @TextRunPropertiesCannotBeNull = "TextRunPropertiesCannotBeNull"; + /// The sum of AccelerationRatio and DecelerationRatio must be less than or equal to one. + internal const string @Timing_AccelAndDecelGreaterThanOne = "Timing_AccelAndDecelGreaterThanOne"; + /// CanSlip is supported only on timelines without AutoReverse, AccelerationRatio, or DecelerationRatio. + internal const string @Timing_CanSlipOnlyOnSimpleTimelines = "Timing_CanSlipOnlyOnSimpleTimelines"; + /// A child of a Timeline in "XAML" must also be a Timeline or a class that derives from Timeline. + internal const string @Timing_ChildMustBeTimeline = "Timing_ChildMustBeTimeline"; + /// The {0}.CreateClock method returned a pre-existing object, rather than a new object inheriting from TimelineClock. + internal const string @Timing_CreateClockMustReturnNewClock = "Timing_CreateClockMustReturnNewClock"; + /// The specified timeline belongs to a different thread than this timeline. + internal const string @Timing_DifferentThreads = "Timing_DifferentThreads"; + /// The enumeration is no longer valid because the collection it enumerates has changed. + internal const string @Timing_EnumeratorInvalidated = "Timing_EnumeratorInvalidated"; + /// The enumerator is out of range. + internal const string @Timing_EnumeratorOutOfRange = "Timing_EnumeratorOutOfRange"; + /// Property value must be between 0.0 and 1.0. + internal const string @Timing_InvalidArgAccelAndDecel = "Timing_InvalidArgAccelAndDecel"; + /// Property value must be finite and greater than or equal to zero. + internal const string @Timing_InvalidArgFiniteNonNegative = "Timing_InvalidArgFiniteNonNegative"; + /// Property value must be finite and greater than zero. + internal const string @Timing_InvalidArgFinitePositive = "Timing_InvalidArgFinitePositive"; + /// Property value must be greater than or equal to zero or indefinite. + internal const string @Timing_InvalidArgNonNegative = "Timing_InvalidArgNonNegative"; + /// Property value must be greater than zero or indefinite. + internal const string @Timing_InvalidArgPositive = "Timing_InvalidArgPositive"; + /// Timeline objects cannot have text objects as children. + internal const string @Timing_NoTextChildren = "Timing_NoTextChildren"; + /// Unable to return a TimeSpan property value for a Duration value of '{0}'. Check the HasTimeSpan property before requesting the TimeSpan property value from a Duration. + internal const string @Timing_NotTimeSpan = "Timing_NotTimeSpan"; + /// A timing operation has been not been queued in the appropriate order. + internal const string @Timing_OperationEnqueuedOutOfOrder = "Timing_OperationEnqueuedOutOfOrder"; + /// '{0}' is not a valid IterationCount value for a RepeatBehavior structure. An IterationCount value must represent a number that is greater than or equal to zero but not infinite. + internal const string @Timing_RepeatBehaviorInvalidIterationCount = "Timing_RepeatBehaviorInvalidIterationCount"; + /// '{0}' is not a valid RepeatDuration value for a RepeatBehavior structure. A RepeatDuration value must be a TimeSpan value greater than or equal to zero ticks. + internal const string @Timing_RepeatBehaviorInvalidRepeatDuration = "Timing_RepeatBehaviorInvalidRepeatDuration"; + /// '{0}' RepeatBehavior does not represent an iteration count and does not have an IterationCount value. + internal const string @Timing_RepeatBehaviorNotIterationCount = "Timing_RepeatBehaviorNotIterationCount"; + /// '{0}' RepeatBehavior does not represent a repeat duration and does not have a RepeatDuration value. + internal const string @Timing_RepeatBehaviorNotRepeatDuration = "Timing_RepeatBehaviorNotRepeatDuration"; + /// The ClockController.Seek method was called with arguments that describe a seek destination that seeks a child with Slip but no defined duration. It is unclear if we are seeking the child or seeking past the child's duration. + internal const string @Timing_SeekDestinationAmbiguousDueToSlip = "Timing_SeekDestinationAmbiguousDueToSlip"; + /// The ClockController.Seek method was called using TimeSeekOrigin.Duration as the seekOrigin parameter for a Clock that has a duration of Forever. Clocks that have duration of Forever must use TimeSeekOrigin.BeginTime. + internal const string @Timing_SeekDestinationIndefinite = "Timing_SeekDestinationIndefinite"; + /// The ClockController.Seek method was called with arguments that describe a seek destination with a negative value. The seek destination must be a time greater than or equal to zero. + internal const string @Timing_SeekDestinationNegative = "Timing_SeekDestinationNegative"; + /// Cannot call the ClockController.SkipToFill method for a Clock that has a Duration or RepeatDuration of Forever, because this Clock will never reach its fill period. + internal const string @Timing_SkipToFillDestinationIndefinite = "Timing_SkipToFillDestinationIndefinite"; + /// SlipBehavior.Slip is supported only on root ParallelTimelines that do not reverse, accelerate, decelerate, or have a RepeatBehavior specified as a Duration. + internal const string @Timing_SlipBehavior_SlipOnlyOnSimpleTimelines = "Timing_SlipBehavior_SlipOnlyOnSimpleTimelines"; + /// Clocks with CanSlip cannot have parents or ancestors with AutoReverse, AccelerationRatio, or DecelerationRatio. + internal const string @Timing_SlipBehavior_SyncOnlyWithSimpleParents = "Timing_SlipBehavior_SyncOnlyWithSimpleParents"; + /// Empty token encountered at position {0} while parsing '{1}'. + internal const string @TokenizerHelperEmptyToken = "TokenizerHelperEmptyToken"; + /// Extra data encountered at position {0} while parsing '{1}'. + internal const string @TokenizerHelperExtraDataEncountered = "TokenizerHelperExtraDataEncountered"; + /// Missing end quote encountered while parsing '{0}'. + internal const string @TokenizerHelperMissingEndQuote = "TokenizerHelperMissingEndQuote"; + /// Premature string termination encountered while parsing '{0}'. + internal const string @TokenizerHelperPrematureStringTermination = "TokenizerHelperPrematureStringTermination"; + /// Too many glyph runs in the scene to render. + internal const string @TooManyGlyphRuns = "TooManyGlyphRuns"; + /// RoutedEvent/EventPrivateKey limit exceeded. Routed events or EventPrivateKey for CLR events are typically static class members registered with field initializers or static constructors. In this case, routed events or EventPrivateKeys might be getting initi ... + internal const string @TooManyRoutedEvents = "TooManyRoutedEvents"; + /// Touch + internal const string @Touch_Category = "Touch_Category"; + /// The TouchDevice is already activated. + internal const string @Touch_DeviceAlreadyActivated = "Touch_DeviceAlreadyActivated"; + /// The TouchDevice is not activated. + internal const string @Touch_DeviceNotActivated = "Touch_DeviceNotActivated"; + /// Potential cycle in tree found while building the event route. + internal const string @TreeLoop = "TreeLoop"; + /// Cannot change property metadata after it has been associated with a property. + internal const string @TypeMetadataCannotChangeAfterUse = "TypeMetadataCannotChangeAfterUse"; + /// Cannot call Arrange on a UIElement with infinite size or NaN. Parent of type '{0}' invokes the UIElement. Arrange called on element of type '{1}'. + internal const string @UIElement_Layout_InfinityArrange = "UIElement_Layout_InfinityArrange"; + /// UIElement.Measure(availableSize) cannot be called with NaN size. + internal const string @UIElement_Layout_NaNMeasure = "UIElement_Layout_NaNMeasure"; + /// Layout measurement override of element '{0}' should not return NaN values as its DesiredSize. + internal const string @UIElement_Layout_NaNReturned = "UIElement_Layout_NaNReturned"; + /// Layout measurement override of element '{0}' should not return PositiveInfinity as its DesiredSize, even if Infinity is passed in as available size. + internal const string @UIElement_Layout_PositiveInfinityReturned = "UIElement_Layout_PositiveInfinityReturned"; + /// Access denied to the path '{0}'. + internal const string @UnauthorizedAccessExceptionWithFileName = "UnauthorizedAccessExceptionWithFileName"; + /// Ctrl+Z + internal const string @UndoKeyDisplayString = "UndoKeyDisplayString"; + /// Undo + internal const string @UndoText = "UndoText"; + /// Parameter is unexpected type '{0}'. Expected type is '{1}'. + internal const string @UnexpectedParameterType = "UnexpectedParameterType"; + /// Unexpected Stroke in PropertyDataChangedEventArgs.Owner. + internal const string @UnexpectedStroke = "UnexpectedStroke"; + /// Unknown path operation attempted. + internal const string @UnknownPathOperationType = "UnknownPathOperationType"; + /// Unrecognized Stroke in PropertyDataChangedEventArgs.Owner. + internal const string @UnknownStroke = "UnknownStroke"; + /// Unrecognized Stroke in Stroke.Invalidated event arguments. + internal const string @UnknownStroke1 = "UnknownStroke1"; + /// Unrecognized Stroke in StrokeCollectionChangedEventArgs.Removed. + internal const string @UnknownStroke3 = "UnknownStroke3"; + /// Failed to initialize GestureRecognizer. + internal const string @UnspecifiedGestureConstructionException = "UnspecifiedGestureConstructionException"; + /// Gesture recognition failed. + internal const string @UnspecifiedGestureException = "UnspecifiedGestureException"; + /// Failed to set enabled gestures. + internal const string @UnspecifiedSetEnabledGesturesException = "UnspecifiedSetEnabledGesturesException"; + /// Unsupported MouseAction '{0}'. + internal const string @Unsupported_MouseAction = "Unsupported_MouseAction"; + /// URI must be absolute. Relative URIs are not supported. + internal const string @UriMustBeAbsolute = "UriMustBeAbsolute"; + /// Font family Uri should have either file:// or pack://application: scheme. + internal const string @UriMustBeFileOrPack = "UriMustBeFileOrPack"; + /// URI must be absolute. + internal const string @UriNotAbsolute = "UriNotAbsolute"; + /// This factory supports only URIs with the '{0}' scheme. + internal const string @UriSchemeMismatch = "UriSchemeMismatch"; + /// UsesPerPixelOpacity is obsolete and should not be set when using UsesPerPixelTransparency + internal const string @UsesPerPixelOpacityIsObsolete = "UsesPerPixelOpacityIsObsolete"; + /// Value is not valid for the specified GUID. + internal const string @ValueNotValidForGuid = "ValueNotValidForGuid"; + /// MaterialGroup cannot be an interactive Material (IsVisualHostMaterial is true). + internal const string @Viewport2DVisual3D_MaterialGroupIsInteractiveMaterial = "Viewport2DVisual3D_MaterialGroupIsInteractiveMaterial"; + /// Viewport2DVisual3D supports only one interactive Material. + internal const string @Viewport2DVisual3D_MultipleInteractiveMaterials = "Viewport2DVisual3D_MultipleInteractiveMaterials"; + /// Specified Visual cannot be detached. + internal const string @VisualCannotBeDetached = "VisualCannotBeDetached"; + /// Specified index is already in use. Disconnect the Visual child at the specified index first. + internal const string @VisualCollection_EntryInUse = "VisualCollection_EntryInUse"; + /// Number of entries exceeds specified capacity of the VisualCollection. + internal const string @VisualCollection_NotEnoughCapacity = "VisualCollection_NotEnoughCapacity"; + /// This VisualCollection is read only and cannot be modified. + internal const string @VisualCollection_ReadOnly = "VisualCollection_ReadOnly"; + /// Specified Visual is already a child of another Visual or the root of a CompositionTarget. + internal const string @VisualCollection_VisualHasParent = "VisualCollection_VisualHasParent"; + /// Another target is already connected to this HostVisual. + internal const string @VisualTarget_AnotherTargetAlreadyConnected = "VisualTarget_AnotherTargetAlreadyConnected"; + /// Specified index is out of range or child at index is null. Do not call this method if VisualChildrenCount returns zero, indicating that the Visual has no children. + internal const string @Visual_ArgumentOutOfRange = "Visual_ArgumentOutOfRange"; + /// This Visual cannot transform the given point. + internal const string @Visual_CannotTransformPoint = "Visual_CannotTransformPoint"; + /// Must disconnect specified child from current parent Visual before attaching to new parent Visual. + internal const string @Visual_HasParent = "Visual_HasParent"; + /// The specified Visual and this Visual do not share a common ancestor, so there is no valid transformation between the two Visuals. + internal const string @Visual_NoCommonAncestor = "Visual_NoCommonAncestor"; + /// This Visual is not connected to a PresentationSource. + internal const string @Visual_NoPresentationSource = "Visual_NoPresentationSource"; + /// '{0}' is not a Visual3D. + internal const string @Visual_NotA3DVisual = "Visual_NotA3DVisual"; + /// The specified Visual is not a descendant of this Visual. + internal const string @Visual_NotADescendant = "Visual_NotADescendant"; + /// The specified Visual is not an ancestor of this Visual. + internal const string @Visual_NotAnAncestor = "Visual_NotAnAncestor"; + /// '{0}' is not a Visual or Visual3D. + internal const string @Visual_NotAVisual = "Visual_NotAVisual"; + /// Specified Visual is not a child of this Visual. + internal const string @Visual_NotChild = "Visual_NotChild"; + /// WebRequest timed out. Response did not arrive before the specified Timeout period elapsed. + internal const string @WebRequestTimeout = "WebRequestTimeout"; + /// Error closing the WebResponse. + internal const string @WebResponseCloseFailure = "WebResponseCloseFailure"; + /// Error processing WebResponse. + internal const string @WebResponseFailure = "WebResponseFailure"; + /// Requested PackagePart not found in target resource. + internal const string @WebResponsePartNotFound = "WebResponsePartNotFound"; + /// Object must be initialized before operation can be performed. + internal const string @WIC_NotInitialized = "WIC_NotInitialized"; + /// Stream does not support writing. + internal const string @WriteNotSupported = "WriteNotSupported"; + /// The required pattern for URI containing ";component" is "AssemblyName;Vxxxx;PublicKey;component", where Vxxxx is the assembly version and PublicKey is the 16-character string representing the assembly public key token. Vxxxx and PublicKey are optional. + internal const string @WrongFirstSegment = "WrongFirstSegment"; + /// There is no registered CultureInfo with the IetfLanguageTag '{0}'. + internal const string @XmlLangGetCultureFailure = "XmlLangGetCultureFailure"; + /// Cannot find non-neutral culture related to '{0}'. + internal const string @XmlLangGetSpecificCulture = "XmlLangGetSpecificCulture"; + /// '{0}' language tag must be empty or must conform to grammar defined in IETF RFC 3066. + internal const string @XmlLangMalformed = "XmlLangMalformed"; + /// + internal const string @ZoomKey = "ZoomKey"; + /// + internal const string @ZoomKeyDisplayString = "ZoomKeyDisplayString"; + /// Zoom + internal const string @ZoomText = "ZoomText"; + /// {0} failed to load from static constructor. + internal const string @PenImcDllVerificationFailed = "PenImcDllVerificationFailed"; + /// SxS COM registration of {0} failed. + internal const string @PenImcSxSRegistrationFailed = "PenImcSxSRegistrationFailed"; + + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/ref/MS.Internal.WindowsBase.SRID.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/ref/MS.Internal.WindowsBase.SRID.cs new file mode 100644 index 0000000..decb658 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/ref/MS.Internal.WindowsBase.SRID.cs @@ -0,0 +1,1146 @@ +// +using System.Reflection; + +namespace FxResources.WindowsBase +{ + internal static class SR { } +} +namespace MS.Internal.WindowsBase +{ + internal static partial class SRID + { + private static global::System.Resources.ResourceManager s_resourceManager; + internal static global::System.Resources.ResourceManager ResourceManager => s_resourceManager ?? (s_resourceManager = new global::System.Resources.ResourceManager(typeof(FxResources.WindowsBase.SR))); + internal static global::System.Globalization.CultureInfo Culture { get; set; } + + [global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + internal static string GetResourceString(string resourceKey, string defaultValue = null) => ResourceManager.GetString(resourceKey, Culture); + /// en + internal const string WPF_UILanguage = nameof(WPF_UILanguage); + /// Cannot modify this property on the Empty Rect. + internal const string Rect_CannotModifyEmptyRect = nameof(Rect_CannotModifyEmptyRect); + /// Cannot call this method on the Empty Rect. + internal const string Rect_CannotCallMethod = nameof(Rect_CannotCallMethod); + /// Width and Height must be non-negative. + internal const string Size_WidthAndHeightCannotBeNegative = nameof(Size_WidthAndHeightCannotBeNegative); + /// Width must be non-negative. + internal const string Size_WidthCannotBeNegative = nameof(Size_WidthCannotBeNegative); + /// Height must be non-negative. + internal const string Size_HeightCannotBeNegative = nameof(Size_HeightCannotBeNegative); + /// Cannot modify this property on the Empty Size. + internal const string Size_CannotModifyEmptySize = nameof(Size_CannotModifyEmptySize); + /// Transform is not invertible. + internal const string Transform_NotInvertible = nameof(Transform_NotInvertible); + /// Expected object of type '{0}'. + internal const string General_Expected_Type = nameof(General_Expected_Type); + /// Value cannot be null. Object reference: '{0}'. + internal const string ReferenceIsNull = nameof(ReferenceIsNull); + /// The parameter value must be between '{0}' and '{1}'. + internal const string ParameterMustBeBetween = nameof(ParameterMustBeBetween); + /// Handler has not been registered with this event. + internal const string Freezable_UnregisteredHandler = nameof(Freezable_UnregisteredHandler); + /// Cannot use a DependencyObject that belongs to a different thread than its parent Freezable. + internal const string Freezable_AttemptToUseInnerValueWithDifferentThread = nameof(Freezable_AttemptToUseInnerValueWithDifferentThread); + /// This Freezable cannot be frozen. + internal const string Freezable_CantFreeze = nameof(Freezable_CantFreeze); + /// The provided DependencyObject is not a context for this Freezable. + internal const string Freezable_NotAContext = nameof(Freezable_NotAContext); + /// Cannot promote from '{0}' to '{1}' because the target map is too small. + internal const string FrugalList_TargetMapCannotHoldAllData = nameof(FrugalList_TargetMapCannotHoldAllData); + /// Cannot promote from Array. + internal const string FrugalList_CannotPromoteBeyondArray = nameof(FrugalList_CannotPromoteBeyondArray); + /// Cannot promote from '{0}' to '{1}' because the target map is too small. + internal const string FrugalMap_TargetMapCannotHoldAllData = nameof(FrugalMap_TargetMapCannotHoldAllData); + /// Cannot promote from Hashtable. + internal const string FrugalMap_CannotPromoteBeyondHashtable = nameof(FrugalMap_CannotPromoteBeyondHashtable); + /// Unrecognized Key '{0}'. + internal const string Unsupported_Key = nameof(Unsupported_Key); + /// Specified priority is not valid. + internal const string InvalidPriority = nameof(InvalidPriority); + /// The minimum priority must be less than or equal to the maximum priority. + internal const string InvalidPriorityRangeOrder = nameof(InvalidPriorityRangeOrder); + /// Cannot perform requested operation because the Dispatcher shut down. + internal const string DispatcherHasShutdown = nameof(DispatcherHasShutdown); + /// A thread cannot wait on operations already running on the same thread. + internal const string ThreadMayNotWaitOnOperationsAlreadyExecutingOnTheSameThread = nameof(ThreadMayNotWaitOnOperationsAlreadyExecutingOnTheSameThread); + /// The calling thread cannot access this object because a different thread owns it. + internal const string VerifyAccess = nameof(VerifyAccess); + /// Objects must be created by the same thread. + internal const string MismatchedDispatchers = nameof(MismatchedDispatchers); + /// Dispatcher processing has been suspended, but messages are still being processed. + internal const string DispatcherProcessingDisabledButStillPumping = nameof(DispatcherProcessingDisabledButStillPumping); + /// Cannot perform this operation while dispatcher processing is suspended. + internal const string DispatcherProcessingDisabled = nameof(DispatcherProcessingDisabled); + /// The DispatcherPriorityAwaiter was not configured with a valid Dispatcher. The only supported usage is from Dispatcher.Yield. + internal const string DispatcherPriorityAwaiterInvalid = nameof(DispatcherPriorityAwaiterInvalid); + /// The thread calling Dispatcher.Yield does not have a current Dispatcher. + internal const string DispatcherYieldNoAvailableDispatcher = nameof(DispatcherYieldNoAvailableDispatcher); + /// The Dispatcher is unable to request processing. This is often because the application has starved the Dispatcher's message pump. + internal const string DispatcherRequestProcessingFailed = nameof(DispatcherRequestProcessingFailed); + /// Exception Filter Code is not built and installed properly. + internal const string ExceptionFilterCodeNotPresent = nameof(ExceptionFilterCodeNotPresent); + /// Unrecognized ModifierKeys '{0}'. + internal const string Unsupported_Modifier = nameof(Unsupported_Modifier); + /// TimeSpan period must be greater than or equal to zero. + internal const string TimeSpanPeriodOutOfRange_TooSmall = nameof(TimeSpanPeriodOutOfRange_TooSmall); + /// TimeSpan period must be less than or equal to Int32.MaxValue. + internal const string TimeSpanPeriodOutOfRange_TooLarge = nameof(TimeSpanPeriodOutOfRange_TooLarge); + /// Cannot clear properties on object '{0}' because it is in a read-only state. + internal const string ClearOnReadOnlyObjectNotAllowed = nameof(ClearOnReadOnlyObjectNotAllowed); + /// Cannot automatically generate a valid default value for property '{0}'. Specify a default value explicitly when owner type '{1}' is registering this DependencyProperty. + internal const string DefaultValueAutoAssignFailed = nameof(DefaultValueAutoAssignFailed); + /// An Expression object is not a valid default value for a DependencyProperty. + internal const string DefaultValueMayNotBeExpression = nameof(DefaultValueMayNotBeExpression); + /// Default value cannot be 'Unset'. + internal const string DefaultValueMayNotBeUnset = nameof(DefaultValueMayNotBeUnset); + /// Default value for the '{0}' property cannot be bound to a specific thread. + internal const string DefaultValueMustBeFreeThreaded = nameof(DefaultValueMustBeFreeThreaded); + /// Default value type does not match type of property '{0}'. + internal const string DefaultValuePropertyTypeMismatch = nameof(DefaultValuePropertyTypeMismatch); + /// Default value for '{0}' property is not valid because ValidateValueCallback failed. + internal const string DefaultValueInvalid = nameof(DefaultValueInvalid); + /// '{0}' type does not have a matching DependencyObjectType. + internal const string DTypeNotSupportForSystemType = nameof(DTypeNotSupportForSystemType); + /// '{0}' is not a valid value for property '{1}'. + internal const string InvalidPropertyValue = nameof(InvalidPropertyValue); + /// Local value enumeration position is out of range. + internal const string LocalValueEnumerationOutOfBounds = nameof(LocalValueEnumerationOutOfBounds); + /// Local value enumeration position is before the start, need to call MoveNext first. + internal const string LocalValueEnumerationReset = nameof(LocalValueEnumerationReset); + /// Current local value enumeration is outdated because one or more local values have been set since its creation. + internal const string LocalValueEnumerationInvalidated = nameof(LocalValueEnumerationInvalidated); + /// Default value factory user must override PropertyMetadata.CreateDefaultValue. + internal const string MissingCreateDefaultValue = nameof(MissingCreateDefaultValue); + /// Metadata override and base metadata must be of the same type or derived type. + internal const string OverridingMetadataDoesNotMatchBaseMetadataType = nameof(OverridingMetadataDoesNotMatchBaseMetadataType); + /// '{0}' property was already registered by '{1}'. + internal const string PropertyAlreadyRegistered = nameof(PropertyAlreadyRegistered); + /// This method overrides metadata only on read-only properties. This property is not read-only. + internal const string PropertyNotReadOnly = nameof(PropertyNotReadOnly); + /// '{0}' property was registered as read-only and cannot be modified without an authorization key. + internal const string ReadOnlyChangeNotAllowed = nameof(ReadOnlyChangeNotAllowed); + /// Property key is not authorized to modify property '{0}'. + internal const string ReadOnlyKeyNotAuthorized = nameof(ReadOnlyKeyNotAuthorized); + /// '{0}' property was registered as read-only and its metadata cannot be overridden without an authorization key. + internal const string ReadOnlyOverrideNotAllowed = nameof(ReadOnlyOverrideNotAllowed); + /// Property key is not authorized to override metadata of property '{0}'. + internal const string ReadOnlyOverrideKeyNotAuthorized = nameof(ReadOnlyOverrideKeyNotAuthorized); + /// '{0}' is registered as read-only, so its value cannot be coerced by using the DesignerCoerceValueCallback. + internal const string ReadOnlyDesignerCoersionNotAllowed = nameof(ReadOnlyDesignerCoersionNotAllowed); + /// Cannot set a property on object '{0}' because it is in a read-only state. + internal const string SetOnReadOnlyObjectNotAllowed = nameof(SetOnReadOnlyObjectNotAllowed); + /// Shareable Expression cannot use ChangeSources method. + internal const string ShareableExpressionsCannotChangeSources = nameof(ShareableExpressionsCannotChangeSources); + /// Cannot set Expression. It is marked as 'NonShareable' and has already been used. + internal const string SharingNonSharableExpression = nameof(SharingNonSharableExpression); + /// ShouldSerializeProperty and ResetProperty methods must be public ('{0}'). + internal const string SpecialMethodMustBePublic = nameof(SpecialMethodMustBePublic); + /// Must create DependencySource on same Thread as the DependencyObject. + internal const string SourcesMustBeInSameThread = nameof(SourcesMustBeInSameThread); + /// Expression is not in use on DependencyObject. Cannot change DependencySource array. + internal const string SourceChangeExpressionMismatch = nameof(SourceChangeExpressionMismatch); + /// DependencyProperty limit has been exceeded upon registration of '{0}'. Dependency properties are normally static class members registered with static field initializers or static constructors. In this case, there may be dependency properties accidentally g ... + internal const string TooManyDependencyProperties = nameof(TooManyDependencyProperties); + /// Metadata is already associated with a type and property. A new one must be created. + internal const string TypeMetadataAlreadyInUse = nameof(TypeMetadataAlreadyInUse); + /// PropertyMetadata is already registered for type '{0}'. + internal const string TypeMetadataAlreadyRegistered = nameof(TypeMetadataAlreadyRegistered); + /// '{0}' type must derive from DependencyObject. + internal const string TypeMustBeDependencyObjectDerived = nameof(TypeMustBeDependencyObjectDerived); + /// Unrecognized Expression 'Mode' value. + internal const string UnknownExpressionMode = nameof(UnknownExpressionMode); + /// Buffer is too small to accommodate the specified parameters. + internal const string BufferTooSmall = nameof(BufferTooSmall); + /// Buffer offset cannot be negative. + internal const string BufferOffsetNegative = nameof(BufferOffsetNegative); + /// CompoundFile path must be non-empty. + internal const string CompoundFilePathNullEmpty = nameof(CompoundFilePathNullEmpty); + /// Cannot create new package on a read-only stream. + internal const string CanNotCreateContainerOnReadOnlyStream = nameof(CanNotCreateContainerOnReadOnlyStream); + /// Cannot create a read-only stream. + internal const string CanNotCreateAsReadOnly = nameof(CanNotCreateAsReadOnly); + /// Cannot create a stream in a read-only package. + internal const string CanNotCreateInReadOnly = nameof(CanNotCreateInReadOnly); + /// Cannot create StorageRoot on a nonreadable stream. + internal const string CanNotCreateStorageRootOnNonReadableStream = nameof(CanNotCreateStorageRootOnNonReadableStream); + /// Cannot delete element. + internal const string CanNotDelete = nameof(CanNotDelete); + /// Cannot delete element because access is denied. + internal const string CanNotDeleteAccessDenied = nameof(CanNotDeleteAccessDenied); + /// Cannot create data storage because access is denied. + internal const string CanNotCreateAccessDenied = nameof(CanNotCreateAccessDenied); + /// Cannot delete read-only packages. + internal const string CanNotDeleteInReadOnly = nameof(CanNotDeleteInReadOnly); + /// Cannot delete because the storage is not empty. Try a recursive delete with Delete(true). + internal const string CanNotDeleteNonEmptyStorage = nameof(CanNotDeleteNonEmptyStorage); + /// Cannot delete the root StorageInfo. + internal const string CanNotDeleteRoot = nameof(CanNotDeleteRoot); + /// Cannot perform this function on a storage that does not exist. + internal const string CanNotOnNonExistStorage = nameof(CanNotOnNonExistStorage); + /// Cannot open data storage. + internal const string CanNotOpenStorage = nameof(CanNotOpenStorage); + /// Cannot find specified package file. + internal const string ContainerNotFound = nameof(ContainerNotFound); + /// Cannot open specified package file. + internal const string ContainerCanNotOpen = nameof(ContainerCanNotOpen); + /// Create mode parameter must be either FileMode.Create or FileMode.Open. + internal const string CreateModeMustBeCreateOrOpen = nameof(CreateModeMustBeCreateOrOpen); + /// Compound File API failure. + internal const string CFAPIFailure = nameof(CFAPIFailure); + /// The given data space label name is already in use. + internal const string DataSpaceLabelInUse = nameof(DataSpaceLabelInUse); + /// Empty string is not a valid data space label. + internal const string DataSpaceLabelInvalidEmpty = nameof(DataSpaceLabelInvalidEmpty); + /// Specified data space label has not been defined. + internal const string DataSpaceLabelUndefined = nameof(DataSpaceLabelUndefined); + /// DataSpaceManager object was disposed. + internal const string DataSpaceManagerDisposed = nameof(DataSpaceManagerDisposed); + /// DataSpace map entry is not valid. + internal const string DataSpaceMapEntryInvalid = nameof(DataSpaceMapEntryInvalid); + /// FileAccess value is not valid. + internal const string FileAccessInvalid = nameof(FileAccessInvalid); + /// File already exists. + internal const string FileAlreadyExists = nameof(FileAlreadyExists); + /// FileMode value is not supported. + internal const string FileModeUnsupported = nameof(FileModeUnsupported); + /// FileMode value is not valid. + internal const string FileModeInvalid = nameof(FileModeInvalid); + /// FileShare value is not supported. + internal const string FileShareUnsupported = nameof(FileShareUnsupported); + /// FileShare value is not valid. + internal const string FileShareInvalid = nameof(FileShareInvalid); + /// Streams for exposure as ILockBytes must be seekable. + internal const string ILockBytesStreamMustSeek = nameof(ILockBytesStreamMustSeek); + /// '{1}' is not a valid value for '{0}'. + internal const string InvalidArgumentValue = nameof(InvalidArgumentValue); + /// Cannot locate information for stream that should exist. This is an internally inconsistent condition. + internal const string InvalidCondition01 = nameof(InvalidCondition01); + /// String format is not valid. + internal const string InvalidStringFormat = nameof(InvalidStringFormat); + /// Internal table type value is not valid. This is an internally inconsistent condition. + internal const string InvalidTableType = nameof(InvalidTableType); + /// MoveTo Destination storage does not exist. + internal const string MoveToDestNotExist = nameof(MoveToDestNotExist); + /// IStorage/IStream::MoveTo not supported. + internal const string MoveToNYI = nameof(MoveToNYI); + /// '{0}' name is already in use. + internal const string NameAlreadyInUse = nameof(NameAlreadyInUse); + /// '{0}' cannot contain the path delimiter: '{1}'. + internal const string NameCanNotHaveDelimiter = nameof(NameCanNotHaveDelimiter); + /// Failed call to '{0}'. + internal const string NamedAPIFailure = nameof(NamedAPIFailure); + /// Name table data is corrupt in data storage. + internal const string NameTableCorruptStg = nameof(NameTableCorruptStg); + /// Name table data is corrupt in memory. + internal const string NameTableCorruptMem = nameof(NameTableCorruptMem); + /// Name table cannot be read by this version of the program. + internal const string NameTableVersionMismatchRead = nameof(NameTableVersionMismatchRead); + /// Name table cannot be updated by this version of the program. + internal const string NameTableVersionMismatchWrite = nameof(NameTableVersionMismatchWrite); + /// This feature is not supported. + internal const string NYIDefault = nameof(NYIDefault); + /// Path string cannot include an empty element. + internal const string PathHasEmptyElement = nameof(PathHasEmptyElement); + /// Count of bytes to read cannot be negative. + internal const string ReadCountNegative = nameof(ReadCountNegative); + /// Cannot seek to given position. + internal const string SeekFailed = nameof(SeekFailed); + /// Cannot set seek pointer to a negative position. + internal const string SeekNegative = nameof(SeekNegative); + /// SeekOrigin value is not valid. + internal const string SeekOriginInvalid = nameof(SeekOriginInvalid); + /// This combination of flags is not supported. + internal const string StorageFlagsUnsupported = nameof(StorageFlagsUnsupported); + /// Storage already exists. + internal const string StorageAlreadyExist = nameof(StorageAlreadyExist); + /// Stream already exists. + internal const string StreamAlreadyExist = nameof(StreamAlreadyExist); + /// StorageInfo object was disposed. + internal const string StorageInfoDisposed = nameof(StorageInfoDisposed); + /// Storage does not exist. + internal const string StorageNotExist = nameof(StorageNotExist); + /// StorageRoot object was disposed. + internal const string StorageRootDisposed = nameof(StorageRootDisposed); + /// StreamInfo object was disposed. + internal const string StreamInfoDisposed = nameof(StreamInfoDisposed); + /// Stream length cannot be negative. + internal const string StreamLengthNegative = nameof(StreamLengthNegative); + /// Cannot perform this function on a stream that does not exist. + internal const string StreamNotExist = nameof(StreamNotExist); + /// Stream name cannot be '{0}'. + internal const string StreamNameNotValid = nameof(StreamNameNotValid); + /// Stream time stamp not implemented in OLE32 implementation of Compound Files. + internal const string StreamTimeStampNotImplemented = nameof(StreamTimeStampNotImplemented); + /// '{0}' cannot start with the reserved character range 0x01-0x1F. + internal const string StringCanNotBeReservedName = nameof(StringCanNotBeReservedName); + /// Requested time stamp is not available. + internal const string TimeStampNotAvailable = nameof(TimeStampNotAvailable); + /// Transform label name is already in use. + internal const string TransformLabelInUse = nameof(TransformLabelInUse); + /// Data space transform stack includes undefined transform labels. + internal const string TransformLabelUndefined = nameof(TransformLabelUndefined); + /// Transform object type is required to have a constructor which takes a TransformEnvironment object. + internal const string TransformObjectConstructorParam = nameof(TransformObjectConstructorParam); + /// Transform object type is required to implement IDataTransform interface. + internal const string TransformObjectImplementIDataTransform = nameof(TransformObjectImplementIDataTransform); + /// Stream transformation failed due to uninitialized data transform objects. + internal const string TransformObjectInitFailed = nameof(TransformObjectInitFailed); + /// Transform identifier type is not supported. + internal const string TransformTypeUnsupported = nameof(TransformTypeUnsupported); + /// Transform stack must have at least one transform. + internal const string TransformStackValid = nameof(TransformStackValid); + /// Cannot create package on stream. + internal const string UnableToCreateOnStream = nameof(UnableToCreateOnStream); + /// Cannot create data storage. + internal const string UnableToCreateStorage = nameof(UnableToCreateStorage); + /// Cannot create data stream. + internal const string UnableToCreateStream = nameof(UnableToCreateStream); + /// Cannot open data stream. + internal const string UnableToOpenStream = nameof(UnableToOpenStream); + /// Encountered unsupported type of storage element when building storage enumerator. + internal const string UnsupportedTypeEncounteredWhenBuildingStgEnum = nameof(UnsupportedTypeEncounteredWhenBuildingStgEnum); + /// Cannot write all data as specified. + internal const string WriteFailure = nameof(WriteFailure); + /// Write-only mode is not supported. + internal const string WriteOnlyUnsupported = nameof(WriteOnlyUnsupported); + /// Cannot write a negative number of bytes. + internal const string WriteSizeNegative = nameof(WriteSizeNegative); + /// Object metadata stream in the package is corrupt and the content is not valid. + internal const string CFM_CorruptMetadataStream = nameof(CFM_CorruptMetadataStream); + /// Object metadata stream in the package is corrupt and the root tag is not valid. + internal const string CFM_CorruptMetadataStream_Root = nameof(CFM_CorruptMetadataStream_Root); + /// Object metadata stream in the package is corrupt with duplicated key tags. + internal const string CFM_CorruptMetadataStream_DuplicateKey = nameof(CFM_CorruptMetadataStream_DuplicateKey); + /// Object used as metadata key must be an instance of the CompoundFileMetadataKey class. + internal const string CFM_ObjectMustBeCompoundFileMetadataKey = nameof(CFM_ObjectMustBeCompoundFileMetadataKey); + /// Cannot perform this operation when the package is in read-only mode. + internal const string CFM_ReadOnlyContainer = nameof(CFM_ReadOnlyContainer); + /// Failed to read a stream type table - the data appears to be a different format. + internal const string CFM_TypeTableFormat = nameof(CFM_TypeTableFormat); + /// Unicode character is not valid. + internal const string CFM_UnicodeCharInvalid = nameof(CFM_UnicodeCharInvalid); + /// Only strings can be used as value. + internal const string CFM_ValueMustBeString = nameof(CFM_ValueMustBeString); + /// XML character is not valid. + internal const string CFM_XMLCharInvalid = nameof(CFM_XMLCharInvalid); + /// Cannot compare different types. + internal const string CanNotCompareDiffTypes = nameof(CanNotCompareDiffTypes); + /// CompoundFileReference: Corrupted CompoundFileReference. + internal const string CFRCorrupt = nameof(CFRCorrupt); + /// CompoundFileReference: Corrupted CompoundFileReference - multiple stream components found. + internal const string CFRCorruptMultiStream = nameof(CFRCorruptMultiStream); + /// CompoundFileReference: Corrupted CompoundFileReference - storage component cannot follow stream component. + internal const string CFRCorruptStgFollowStm = nameof(CFRCorruptStgFollowStm); + /// Cannot have leading path delimiter. + internal const string DelimiterLeading = nameof(DelimiterLeading); + /// Cannot have trailing path delimiter. + internal const string DelimiterTrailing = nameof(DelimiterTrailing); + /// Offset must be greater than or equal to zero. + internal const string OffsetNegative = nameof(OffsetNegative); + /// Unrecognized reference component type. + internal const string UnknownReferenceComponentType = nameof(UnknownReferenceComponentType); + /// Cannot serialize unknown CompoundFileReference subclass. + internal const string UnknownReferenceSerialize = nameof(UnknownReferenceSerialize); + /// CompoundFileReference: malformed path encountered. + internal const string MalformedCompoundFilePath = nameof(MalformedCompoundFilePath); + /// Stream length cannot be negative. + internal const string CannotMakeStreamLengthNegative = nameof(CannotMakeStreamLengthNegative); + /// Stream operation failed because stream is corrupted. + internal const string CorruptStream = nameof(CorruptStream); + /// Stream does not support Length property. + internal const string LengthNotSupported = nameof(LengthNotSupported); + /// Buffer too small to hold results of Read. + internal const string ReadBufferTooSmall = nameof(ReadBufferTooSmall); + /// Stream does not support reading. + internal const string ReadNotSupported = nameof(ReadNotSupported); + /// Stream does not support Seek. + internal const string SeekNotSupported = nameof(SeekNotSupported); + /// Stream does not support SetLength. + internal const string SetLengthNotSupported = nameof(SetLengthNotSupported); + /// Stream does not support setting the Position property. + internal const string SetPositionNotSupported = nameof(SetPositionNotSupported); + /// Negative stream position not supported. + internal const string StreamPositionNegative = nameof(StreamPositionNegative); + /// Cannot change Transform parameters after the transform is initialized. + internal const string TransformParametersFixed = nameof(TransformParametersFixed); + /// Buffer of bytes to be written is too small. + internal const string WriteBufferTooSmall = nameof(WriteBufferTooSmall); + /// Count of bytes to write cannot be negative. + internal const string WriteCountNegative = nameof(WriteCountNegative); + /// Stream does not support writing. + internal const string WriteNotSupported = nameof(WriteNotSupported); + /// Compression requires ZLib library version {0}. + internal const string ZLibVersionError = nameof(ZLibVersionError); + /// Expected a VersionPair object. + internal const string ExpectedVersionPairObject = nameof(ExpectedVersionPairObject); + /// Major and minor version number components cannot be negative. + internal const string VersionNumberComponentNegative = nameof(VersionNumberComponentNegative); + /// Feature ID string cannot have zero length. + internal const string ZeroLengthFeatureID = nameof(ZeroLengthFeatureID); + /// Cannot find version stream. + internal const string VersionStreamMissing = nameof(VersionStreamMissing); + /// Cannot update version because of a version field size mismatch. + internal const string VersionUpdateFailure = nameof(VersionUpdateFailure); + /// Cannot remove signature from read-only file. + internal const string CannotRemoveSignatureFromReadOnlyFile = nameof(CannotRemoveSignatureFromReadOnlyFile); + /// Cannot sign read-only file. + internal const string CannotSignReadOnlyFile = nameof(CannotSignReadOnlyFile); + /// Cannot locate the selected digital certificate. + internal const string DigSigCannotLocateCertificate = nameof(DigSigCannotLocateCertificate); + /// Certificate error. Multiple certificates found with the same thumbprint. + internal const string DigSigDuplicateCertificate = nameof(DigSigDuplicateCertificate); + /// Digital Signature + internal const string CertSelectionDialogTitle = nameof(CertSelectionDialogTitle); + /// Select a certificate + internal const string CertSelectionDialogMessage = nameof(CertSelectionDialogMessage); + /// Duplicates not allowed - signature part already exists. + internal const string DuplicateSignature = nameof(DuplicateSignature); + /// Error parsing XML Signature. + internal const string XmlSignatureParseError = nameof(XmlSignatureParseError); + /// Required attribute '{0}' not found. + internal const string RequiredXmlAttributeMissing = nameof(RequiredXmlAttributeMissing); + /// Unexpected tag '{0}'. + internal const string UnexpectedXmlTag = nameof(UnexpectedXmlTag); + /// Required tag '{0}' not found. + internal const string RequiredTagNotFound = nameof(RequiredTagNotFound); + /// Required Package-specific Object tag is missing. + internal const string PackageSignatureObjectTagRequired = nameof(PackageSignatureObjectTagRequired); + /// Required Package-specific Reference tag is missing. + internal const string PackageSignatureReferenceTagRequired = nameof(PackageSignatureReferenceTagRequired); + /// Expected exactly one Package-specific Reference tag. + internal const string MoreThanOnePackageSpecificReference = nameof(MoreThanOnePackageSpecificReference); + /// Uri attribute in Reference tag must refer using fragment identifiers. + internal const string InvalidUriAttribute = nameof(InvalidUriAttribute); + /// Cannot countersign an unsigned package. + internal const string NoCounterSignUnsignedContainer = nameof(NoCounterSignUnsignedContainer); + /// Time format string is not valid. + internal const string BadSignatureTimeFormatString = nameof(BadSignatureTimeFormatString); + /// Signature structures are corrupted in this package. + internal const string PackageSignatureCorruption = nameof(PackageSignatureCorruption); + /// Unsupported hash algorithm specified. + internal const string UnsupportedHashAlgorithm = nameof(UnsupportedHashAlgorithm); + /// Relationship transform must be followed by an XML canonicalization transform. + internal const string RelationshipTransformNotFollowedByCanonicalizationTransform = nameof(RelationshipTransformNotFollowedByCanonicalizationTransform); + /// There must be at most one relationship transform specified for a given relationship part. + internal const string MultipleRelationshipTransformsFound = nameof(MultipleRelationshipTransformsFound); + /// Unsupported transform algorithm specified. + internal const string UnsupportedTransformAlgorithm = nameof(UnsupportedTransformAlgorithm); + /// Unsupported canonicalization method specified. + internal const string UnsupportedCanonicalizationMethod = nameof(UnsupportedCanonicalizationMethod); + /// Reusable hash algorithm must be specified. + internal const string HashAlgorithmMustBeReusable = nameof(HashAlgorithmMustBeReusable); + /// Malformed Part URI in Reference tag. + internal const string PartReferenceUriMalformed = nameof(PartReferenceUriMalformed); + /// Relationship was found to the signature origin but the part is missing. Package signature structures are corrupted. + internal const string SignatureOriginNotFound = nameof(SignatureOriginNotFound); + /// Multiple signature origin relationships found. + internal const string MultipleSignatureOrigins = nameof(MultipleSignatureOrigins); + /// Must specify an item to sign. + internal const string NothingToSign = nameof(NothingToSign); + /// Signature Identifier cannot be empty. + internal const string EmptySignatureId = nameof(EmptySignatureId); + /// Signature was deleted. + internal const string SignatureDeleted = nameof(SignatureDeleted); + /// Specified object ID conflicts with predefined Package Object ID. + internal const string SignaturePackageObjectTagMustBeUnique = nameof(SignaturePackageObjectTagMustBeUnique); + /// Specified reference object conflicts with predefined Package specific reference. + internal const string PackageSpecificReferenceTagMustBeUnique = nameof(PackageSpecificReferenceTagMustBeUnique); + /// Object identifiers must be unique within the same signature. + internal const string SignatureObjectIdMustBeUnique = nameof(SignatureObjectIdMustBeUnique); + /// Can only countersign parts with Digital Signature ContentType. + internal const string CanOnlyCounterSignSignatureParts = nameof(CanOnlyCounterSignSignatureParts); + /// Certificate part is not of the correct type. + internal const string CertificatePartContentTypeMismatch = nameof(CertificatePartContentTypeMismatch); + /// Signing certificate must be of type DSA or RSA. + internal const string CertificateKeyTypeNotSupported = nameof(CertificateKeyTypeNotSupported); + /// Specified part to sign does not exist. + internal const string PartToSignMissing = nameof(PartToSignMissing); + /// Duplicate object ID found. IDs must be unique within the signature XML. + internal const string DuplicateObjectId = nameof(DuplicateObjectId); + /// Caller-supplied parameter to callback function is not of expected type. + internal const string CallbackParameterInvalid = nameof(CallbackParameterInvalid); + /// Cannot change publish license after the rights management transform settings are fixed. + internal const string CannotChangePublishLicense = nameof(CannotChangePublishLicense); + /// Cannot change CryptoProvider after the rights management transform settings are fixed. + internal const string CannotChangeCryptoProvider = nameof(CannotChangeCryptoProvider); + /// Length prefix specifies {0} characters, which exceeds the maximum of {1} characters. + internal const string ExcessiveLengthPrefix = nameof(ExcessiveLengthPrefix); + /// OLE property ID {0} cannot be read (error {1}). + internal const string GetOlePropertyFailed = nameof(GetOlePropertyFailed); + /// Authentication type string (the part before the colon) is not valid in user ID '{0}'. + internal const string InvalidAuthenticationTypeString = nameof(InvalidAuthenticationTypeString); + /// '{0}' document property type is not valid. + internal const string InvalidDocumentPropertyType = nameof(InvalidDocumentPropertyType); + /// '{0}' document property variant type is not valid. + internal const string InvalidDocumentPropertyVariantType = nameof(InvalidDocumentPropertyVariantType); + /// User ID in use license stream is not of the form "authenticationType:userName". + internal const string InvalidTypePrefixedUserName = nameof(InvalidTypePrefixedUserName); + /// Feature name in the transform's primary stream is '{0}', but expected '{1}'. + internal const string InvalidTransformFeatureName = nameof(InvalidTransformFeatureName); + /// Document does not contain a package. + internal const string PackageNotFound = nameof(PackageNotFound); + /// File does not contain a stream to hold the publish license. + internal const string NoPublishLicenseStream = nameof(NoPublishLicenseStream); + /// File does not contain a storage to hold use licenses. + internal const string NoUseLicenseStorage = nameof(NoUseLicenseStorage); + /// File contains data in format version {0}, but the software can only read that data in format version {1} or lower. + internal const string ReaderVersionError = nameof(ReaderVersionError); + /// Document's publish license stream is corrupted. + internal const string PublishLicenseStreamCorrupt = nameof(PublishLicenseStreamCorrupt); + /// Document does not contain a publish license. + internal const string PublishLicenseNotFound = nameof(PublishLicenseNotFound); + /// Document does not contain any rights management-protected streams. + internal const string RightsManagementEncryptionTransformNotFound = nameof(RightsManagementEncryptionTransformNotFound); + /// Document contains multiple Rights Management Encryption Transforms. + internal const string MultipleRightsManagementEncryptionTransformFound = nameof(MultipleRightsManagementEncryptionTransformFound); + /// The stream on which the encrypted package is created must have read/write access. + internal const string StreamNeedsReadWriteAccess = nameof(StreamNeedsReadWriteAccess); + /// Cannot perform stream operation because CryptoProvider is not set to allow decryption. + internal const string CryptoProviderCanNotDecrypt = nameof(CryptoProviderCanNotDecrypt); + /// Only cryptographic providers based on a block cipher are supported. + internal const string CryptoProviderCanNotMergeBlocks = nameof(CryptoProviderCanNotMergeBlocks); + /// EncryptedPackageEnvelope object was disposed. + internal const string EncryptedPackageEnvelopeDisposed = nameof(EncryptedPackageEnvelopeDisposed); + /// CryptoProvider object was disposed. + internal const string CryptoProviderDisposed = nameof(CryptoProviderDisposed); + /// File contains data in format version {0}, but the software can only update that data in format version {1} or lower. + internal const string UpdaterVersionError = nameof(UpdaterVersionError); + /// The dictionary is read-only. + internal const string DictionaryIsReadOnly = nameof(DictionaryIsReadOnly); + /// The CryptoProvider cannot encrypt or decrypt. + internal const string CryptoProviderIsNotReady = nameof(CryptoProviderIsNotReady); + /// One of the document's use licenses is corrupted. + internal const string UseLicenseStreamCorrupt = nameof(UseLicenseStreamCorrupt); + /// Encrypted data stream is corrupted. + internal const string EncryptedDataStreamCorrupt = nameof(EncryptedDataStreamCorrupt); + /// Unrecognized document property: FMTID = '{0}', property ID = '{1}'. + internal const string UnknownDocumentProperty = nameof(UnknownDocumentProperty); + /// '{0}' document property in property set '{1}' is of incorrect variant type '{2}'. Expected type '{3}'. + internal const string WrongDocumentPropertyVariantType = nameof(WrongDocumentPropertyVariantType); + /// User is not activated. + internal const string UserIsNotActivated = nameof(UserIsNotActivated); + /// User does not have a client licensor certificate. + internal const string UserHasNoClientLicensorCert = nameof(UserHasNoClientLicensorCert); + /// Encryption right is not granted. + internal const string EncryptionRightIsNotGranted = nameof(EncryptionRightIsNotGranted); + /// Decryption right is not granted. + internal const string DecryptionRightIsNotGranted = nameof(DecryptionRightIsNotGranted); + /// CryptoProvider does not have privileges required for decryption of the PublishLicense. + internal const string NoPrivilegesForPublishLicenseDecryption = nameof(NoPrivilegesForPublishLicenseDecryption); + /// Signed Publish License is not valid. + internal const string InvalidPublishLicense = nameof(InvalidPublishLicense); + /// Variable-length header in publish license stream is {0} bytes, which exceeds the maximum length of {1} bytes. + internal const string PublishLicenseStreamHeaderTooLong = nameof(PublishLicenseStreamHeaderTooLong); + /// User must be either Windows or Passport authenticated. Other authentication types are not allowed in this context. + internal const string OnlyPassportOrWindowsAuthenticatedUsersAreAllowed = nameof(OnlyPassportOrWindowsAuthenticatedUsersAreAllowed); + /// Rights management operation failed. + internal const string RmExceptionGenericMessage = nameof(RmExceptionGenericMessage); + /// License is not valid. + internal const string RmExceptionInvalidLicense = nameof(RmExceptionInvalidLicense); + /// Information not found. + internal const string RmExceptionInfoNotInLicense = nameof(RmExceptionInfoNotInLicense); + /// License signature is not valid. + internal const string RmExceptionInvalidLicenseSignature = nameof(RmExceptionInvalidLicenseSignature); + /// Encryption not permitted. + internal const string RmExceptionEncryptionNotPermitted = nameof(RmExceptionEncryptionNotPermitted); + /// Right not granted. + internal const string RmExceptionRightNotGranted = nameof(RmExceptionRightNotGranted); + /// Version is not valid. + internal const string RmExceptionInvalidVersion = nameof(RmExceptionInvalidVersion); + /// Encoding type is not valid. + internal const string RmExceptionInvalidEncodingType = nameof(RmExceptionInvalidEncodingType); + /// Numerical value is not valid. + internal const string RmExceptionInvalidNumericalValue = nameof(RmExceptionInvalidNumericalValue); + /// Algorithm type is not valid. + internal const string RmExceptionInvalidAlgorithmType = nameof(RmExceptionInvalidAlgorithmType); + /// Environment not loaded. + internal const string RmExceptionEnvironmentNotLoaded = nameof(RmExceptionEnvironmentNotLoaded); + /// Cannot load environment. + internal const string RmExceptionEnvironmentCannotLoad = nameof(RmExceptionEnvironmentCannotLoad); + /// Cannot load more than one environment. + internal const string RmExceptionTooManyLoadedEnvironments = nameof(RmExceptionTooManyLoadedEnvironments); + /// Incompatible objects. + internal const string RmExceptionIncompatibleObjects = nameof(RmExceptionIncompatibleObjects); + /// Library fail. + internal const string RmExceptionLibraryFail = nameof(RmExceptionLibraryFail); + /// Enabling principal failure. + internal const string RmExceptionEnablingPrincipalFailure = nameof(RmExceptionEnablingPrincipalFailure); + /// Information not found. + internal const string RmExceptionInfoNotPresent = nameof(RmExceptionInfoNotPresent); + /// Get information query is not valid. + internal const string RmExceptionBadGetInfoQuery = nameof(RmExceptionBadGetInfoQuery); + /// Key type not supported. + internal const string RmExceptionKeyTypeUnsupported = nameof(RmExceptionKeyTypeUnsupported); + /// Crypto operation not supported. + internal const string RmExceptionCryptoOperationUnsupported = nameof(RmExceptionCryptoOperationUnsupported); + /// Clock rollback detected. + internal const string RmExceptionClockRollbackDetected = nameof(RmExceptionClockRollbackDetected); + /// Query reports no results. + internal const string RmExceptionQueryReportsNoResults = nameof(RmExceptionQueryReportsNoResults); + /// Unexpected exception. + internal const string RmExceptionUnexpectedException = nameof(RmExceptionUnexpectedException); + /// Binding validity time violated. + internal const string RmExceptionBindValidityTimeViolated = nameof(RmExceptionBindValidityTimeViolated); + /// Broken certificate chain. + internal const string RmExceptionBrokenCertChain = nameof(RmExceptionBrokenCertChain); + /// Binding policy violation. + internal const string RmExceptionBindPolicyViolation = nameof(RmExceptionBindPolicyViolation); + /// Manifest policy violation. + internal const string RmExceptionManifestPolicyViolation = nameof(RmExceptionManifestPolicyViolation); + /// License has been revoked. + internal const string RmExceptionBindRevokedLicense = nameof(RmExceptionBindRevokedLicense); + /// Issuer has been revoked. + internal const string RmExceptionBindRevokedIssuer = nameof(RmExceptionBindRevokedIssuer); + /// Principal has been revoked. + internal const string RmExceptionBindRevokedPrincipal = nameof(RmExceptionBindRevokedPrincipal); + /// Resource has been revoked. + internal const string RmExceptionBindRevokedResource = nameof(RmExceptionBindRevokedResource); + /// Module has been revoked. + internal const string RmExceptionBindRevokedModule = nameof(RmExceptionBindRevokedModule); + /// Binding content not in the End Use License. + internal const string RmExceptionBindContentNotInEndUseLicense = nameof(RmExceptionBindContentNotInEndUseLicense); + /// Binding access principal is not enabling. + internal const string RmExceptionBindAccessPrincipalNotEnabling = nameof(RmExceptionBindAccessPrincipalNotEnabling); + /// Binding access unsatisfied. + internal const string RmExceptionBindAccessUnsatisfied = nameof(RmExceptionBindAccessUnsatisfied); + /// Principal provided for binding is missing. + internal const string RmExceptionBindIndicatedPrincipalMissing = nameof(RmExceptionBindIndicatedPrincipalMissing); + /// Machine is not found in group identity certificate. + internal const string RmExceptionBindMachineNotFoundInGroupIdentity = nameof(RmExceptionBindMachineNotFoundInGroupIdentity); + /// Unsupported library plug-in. + internal const string RmExceptionLibraryUnsupportedPlugIn = nameof(RmExceptionLibraryUnsupportedPlugIn); + /// Binding revocation list is stale. + internal const string RmExceptionBindRevocationListStale = nameof(RmExceptionBindRevocationListStale); + /// Binding missing application revocation list. + internal const string RmExceptionBindNoApplicableRevocationList = nameof(RmExceptionBindNoApplicableRevocationList); + /// Handle is not valid. + internal const string RmExceptionInvalidHandle = nameof(RmExceptionInvalidHandle); + /// Binding time interval is violated. + internal const string RmExceptionBindIntervalTimeViolated = nameof(RmExceptionBindIntervalTimeViolated); + /// Binding cannot find a satisfied rights group. + internal const string RmExceptionBindNoSatisfiedRightsGroup = nameof(RmExceptionBindNoSatisfiedRightsGroup); + /// Cannot find content specified for binding. + internal const string RmExceptionBindSpecifiedWorkMissing = nameof(RmExceptionBindSpecifiedWorkMissing); + /// No more data. + internal const string RmExceptionNoMoreData = nameof(RmExceptionNoMoreData); + /// License acquisition failed. + internal const string RmExceptionLicenseAcquisitionFailed = nameof(RmExceptionLicenseAcquisitionFailed); + /// ID mismatch. + internal const string RmExceptionIdMismatch = nameof(RmExceptionIdMismatch); + /// Cannot have more than one certificate. + internal const string RmExceptionTooManyCertificates = nameof(RmExceptionTooManyCertificates); + /// Distribution Point URL was not set. + internal const string RmExceptionNoDistributionPointUrlFound = nameof(RmExceptionNoDistributionPointUrlFound); + /// Rights management server transaction already in progress. + internal const string RmExceptionAlreadyInProgress = nameof(RmExceptionAlreadyInProgress); + /// Group identity not set. + internal const string RmExceptionGroupIdentityNotSet = nameof(RmExceptionGroupIdentityNotSet); + /// Record not found. + internal const string RmExceptionRecordNotFound = nameof(RmExceptionRecordNotFound); + /// Connection failed. + internal const string RmExceptionNoConnect = nameof(RmExceptionNoConnect); + /// License not found. + internal const string RmExceptionNoLicense = nameof(RmExceptionNoLicense); + /// Machine must be activated. + internal const string RmExceptionNeedsMachineActivation = nameof(RmExceptionNeedsMachineActivation); + /// User identity must be activated. + internal const string RmExceptionNeedsGroupIdentityActivation = nameof(RmExceptionNeedsGroupIdentityActivation); + /// Activation failed. + internal const string RmExceptionActivationFailed = nameof(RmExceptionActivationFailed); + /// Command interrupted. + internal const string RmExceptionAborted = nameof(RmExceptionAborted); + /// Transaction quota exceeded. + internal const string RmExceptionOutOfQuota = nameof(RmExceptionOutOfQuota); + /// Authentication failed. + internal const string RmExceptionAuthenticationFailed = nameof(RmExceptionAuthenticationFailed); + /// Server side error. + internal const string RmExceptionServerError = nameof(RmExceptionServerError); + /// Installation failed. + internal const string RmExceptionInstallationFailed = nameof(RmExceptionInstallationFailed); + /// Hardware ID corrupted. + internal const string RmExceptionHidCorrupted = nameof(RmExceptionHidCorrupted); + /// Server response is not valid. + internal const string RmExceptionInvalidServerResponse = nameof(RmExceptionInvalidServerResponse); + /// Service not found. + internal const string RmExceptionServiceNotFound = nameof(RmExceptionServiceNotFound); + /// Use default. + internal const string RmExceptionUseDefault = nameof(RmExceptionUseDefault); + /// Server not found. + internal const string RmExceptionServerNotFound = nameof(RmExceptionServerNotFound); + /// E-mail address is not valid. + internal const string RmExceptionInvalidEmail = nameof(RmExceptionInvalidEmail); + /// License validity time violation. + internal const string RmExceptionValidityTimeViolation = nameof(RmExceptionValidityTimeViolation); + /// Outdated module. + internal const string RmExceptionOutdatedModule = nameof(RmExceptionOutdatedModule); + /// Service moved. + internal const string RmExceptionServiceMoved = nameof(RmExceptionServiceMoved); + /// Service gone. + internal const string RmExceptionServiceGone = nameof(RmExceptionServiceGone); + /// Ad entry not found. + internal const string RmExceptionAdEntryNotFound = nameof(RmExceptionAdEntryNotFound); + /// Not a certificate chain. + internal const string RmExceptionNotAChain = nameof(RmExceptionNotAChain); + /// Rights management server denied request. + internal const string RmExceptionRequestDenied = nameof(RmExceptionRequestDenied); + /// Not set. + internal const string RmExceptionNotSet = nameof(RmExceptionNotSet); + /// Metadata not set. + internal const string RmExceptionMetadataNotSet = nameof(RmExceptionMetadataNotSet); + /// Revocation information not set. + internal const string RmExceptionRevocationInfoNotSet = nameof(RmExceptionRevocationInfoNotSet); + /// Time information is not valid. + internal const string RmExceptionInvalidTimeInfo = nameof(RmExceptionInvalidTimeInfo); + /// Right not set. + internal const string RmExceptionRightNotSet = nameof(RmExceptionRightNotSet); + /// License binding to Windows Identity failed (NTLM bind failure). + internal const string RmExceptionLicenseBindingToWindowsIdentityFailed = nameof(RmExceptionLicenseBindingToWindowsIdentityFailed); + /// Issuance license template is not valid because of incorrectly formatted string. + internal const string RmExceptionInvalidIssuanceLicenseTemplate = nameof(RmExceptionInvalidIssuanceLicenseTemplate); + /// Key size length is not valid. + internal const string RmExceptionInvalidKeyLength = nameof(RmExceptionInvalidKeyLength); + /// Expired official Publish License template. + internal const string RmExceptionExpiredOfficialIssuanceLicenseTemplate = nameof(RmExceptionExpiredOfficialIssuanceLicenseTemplate); + /// Client Licensor Certificate is not valid. + internal const string RmExceptionInvalidClientLicensorCertificate = nameof(RmExceptionInvalidClientLicensorCertificate); + /// Hardware ID is not valid. + internal const string RmExceptionHidInvalid = nameof(RmExceptionHidInvalid); + /// E-mail not verified. + internal const string RmExceptionEmailNotVerified = nameof(RmExceptionEmailNotVerified); + /// Debugger detected. + internal const string RmExceptionDebuggerDetected = nameof(RmExceptionDebuggerDetected); + /// Lockbox type is not valid. + internal const string RmExceptionInvalidLockboxType = nameof(RmExceptionInvalidLockboxType); + /// Lockbox path is not valid. + internal const string RmExceptionInvalidLockboxPath = nameof(RmExceptionInvalidLockboxPath); + /// Registry path is not valid. + internal const string RmExceptionInvalidRegistryPath = nameof(RmExceptionInvalidRegistryPath); + /// No AES Crypto provider found. + internal const string RmExceptionNoAesCryptoProvider = nameof(RmExceptionNoAesCryptoProvider); + /// Global option is already set. + internal const string RmExceptionGlobalOptionAlreadySet = nameof(RmExceptionGlobalOptionAlreadySet); + /// Owner's license not found. + internal const string RmExceptionOwnerLicenseNotFound = nameof(RmExceptionOwnerLicenseNotFound); + /// Archive file cannot be size 0. + internal const string ZipZeroSizeFileIsNotValidArchive = nameof(ZipZeroSizeFileIsNotValidArchive); + /// Cannot perform a write operation in read-only mode. + internal const string CanNotWriteInReadOnlyMode = nameof(CanNotWriteInReadOnlyMode); + /// Cannot perform a read operation in write-only mode. + internal const string CanNotReadInWriteOnlyMode = nameof(CanNotReadInWriteOnlyMode); + /// Cannot perform a read/write operation in write-only or read-only modes. + internal const string CanNotReadWriteInReadOnlyWriteOnlyMode = nameof(CanNotReadWriteInReadOnlyWriteOnlyMode); + /// Cannot create file because the specified file name is already in use. + internal const string AttemptedToCreateDuplicateFileName = nameof(AttemptedToCreateDuplicateFileName); + /// Cannot find specified file. + internal const string FileDoesNotExists = nameof(FileDoesNotExists); + /// Truncate and Append FileModes are not supported. + internal const string TruncateAppendModesNotSupported = nameof(TruncateAppendModesNotSupported); + /// Only FileShare.Read and FileShare.None are supported. + internal const string OnlyFileShareReadAndFileShareNoneSupported = nameof(OnlyFileShareReadAndFileShareNoneSupported); + /// Cannot read data from stream that does not support reading. + internal const string CanNotReadDataFromStreamWhichDoesNotSupportReading = nameof(CanNotReadDataFromStreamWhichDoesNotSupportReading); + /// Cannot write data to stream that does not support writing. + internal const string CanNotWriteDataToStreamWhichDoesNotSupportWriting = nameof(CanNotWriteDataToStreamWhichDoesNotSupportWriting); + /// Cannot operate on stream that does not support seeking. + internal const string CanNotOperateOnStreamWhichDoesNotSupportSeeking = nameof(CanNotOperateOnStreamWhichDoesNotSupportSeeking); + /// Cannot get stream with FileMode.Create, FileMode.CreateNew, FileMode.Truncate, FileMode.Append when access is FileAccess.Read. + internal const string UnsupportedCombinationOfModeAccessShareStreaming = nameof(UnsupportedCombinationOfModeAccessShareStreaming); + /// File contains corrupted data. + internal const string CorruptedData = nameof(CorruptedData); + /// Multidisk ZIP format is not supported. + internal const string NotSupportedMultiDisk = nameof(NotSupportedMultiDisk); + /// ZIP archive was closed and disposed. + internal const string ZipArchiveDisposed = nameof(ZipArchiveDisposed); + /// ZIP file was closed, disposed, or deleted. + internal const string ZipFileItemDisposed = nameof(ZipFileItemDisposed); + /// ZIP archive contains unsupported data structures. + internal const string NotSupportedVersionNeededToExtract = nameof(NotSupportedVersionNeededToExtract); + /// ZIP archive contains data structures too large to fit in memory. + internal const string Zip64StructuresTooLarge = nameof(Zip64StructuresTooLarge); + /// ZIP archive contains unsupported encrypted data. + internal const string ZipNotSupportedEncryptedArchive = nameof(ZipNotSupportedEncryptedArchive); + /// ZIP archive contains unsupported signature data. + internal const string ZipNotSupportedSignedArchive = nameof(ZipNotSupportedSignedArchive); + /// ZIP archive contains data compressed using an unsupported algorithm. + internal const string ZipNotSupportedCompressionMethod = nameof(ZipNotSupportedCompressionMethod); + /// Compressed part has inconsistent data length. + internal const string CompressLengthMismatch = nameof(CompressLengthMismatch); + /// CreateNew is not a valid FileMode for a nonempty stream. + internal const string CreateNewOnNonEmptyStream = nameof(CreateNewOnNonEmptyStream); + /// Specified part does not exist in the package. + internal const string PartDoesNotExist = nameof(PartDoesNotExist); + /// Cannot add part for the specified URI because it is already in the package. + internal const string PartAlreadyExists = nameof(PartAlreadyExists); + /// Cannot add part to the package. Part names cannot be derived from another part name by appending segments to it. + internal const string PartNamePrefixExists = nameof(PartNamePrefixExists); + /// Cannot open package because FileMode or FileAccess value is not valid for the stream. + internal const string IncompatibleModeOrAccess = nameof(IncompatibleModeOrAccess); + /// Cannot be an absolute URI. + internal const string URIShouldNotBeAbsolute = nameof(URIShouldNotBeAbsolute); + /// Must have absolute URI. + internal const string UriShouldBeAbsolute = nameof(UriShouldBeAbsolute); + /// FileMode/FileAccess for Part.GetStream is not compatible with FileMode/FileAccess used to open the Package. + internal const string ContainerAndPartModeIncompatible = nameof(ContainerAndPartModeIncompatible); + /// Cannot get stream with FileMode.Create, FileMode.CreateNew, FileMode.Truncate, FileMode.Append when access is FileAccess.Read. + internal const string UnsupportedCombinationOfModeAccess = nameof(UnsupportedCombinationOfModeAccess); + /// Returned stream for the part is null. + internal const string NullStreamReturned = nameof(NullStreamReturned); + /// Package object was closed and disposed, so cannot carry out operations on this object or any stream opened on a part of this package. + internal const string ObjectDisposed = nameof(ObjectDisposed); + /// Cannot write to read-only stream. + internal const string ReadOnlyStream = nameof(ReadOnlyStream); + /// Cannot read from write-only stream. + internal const string WriteOnlyStream = nameof(WriteOnlyStream); + /// Cannot access part because parent package was closed. + internal const string ParentContainerClosed = nameof(ParentContainerClosed); + /// Part was deleted. + internal const string PackagePartDeleted = nameof(PackagePartDeleted); + /// PackageRelationship cannot target another PackageRelationship. + internal const string RelationshipToRelationshipIllegal = nameof(RelationshipToRelationshipIllegal); + /// PackageRelationship parts cannot have relationships to other parts. + internal const string RelationshipPartsCannotHaveRelationships = nameof(RelationshipPartsCannotHaveRelationships); + /// Incorrect content type for PackageRelationship part. + internal const string RelationshipPartIncorrectContentType = nameof(RelationshipPartIncorrectContentType); + /// PackageRelationship with specified ID does not exist at the Package level. + internal const string PackageRelationshipDoesNotExist = nameof(PackageRelationshipDoesNotExist); + /// PackageRelationship with specified ID does not exist for the source part. + internal const string PackagePartRelationshipDoesNotExist = nameof(PackagePartRelationshipDoesNotExist); + /// PackageRelationship target must be relative URI if TargetMode is Internal. + internal const string RelationshipTargetMustBeRelative = nameof(RelationshipTargetMustBeRelative); + /// Relationship tag requires attribute '{0}'. + internal const string RequiredRelationshipAttributeMissing = nameof(RequiredRelationshipAttributeMissing); + /// Relationship tag contains incorrect attribute. + internal const string RelationshipTagDoesntMatchSchema = nameof(RelationshipTagDoesntMatchSchema); + /// Relationships tag has extra attributes. + internal const string RelationshipsTagHasExtraAttributes = nameof(RelationshipsTagHasExtraAttributes); + /// Unrecognized tag found in Relationships XML. + internal const string UnknownTagEncountered = nameof(UnknownTagEncountered); + /// Relationships tag expected at root level. + internal const string ExpectedRelationshipsElementTag = nameof(ExpectedRelationshipsElementTag); + /// Relationships XML elements cannot specify attribute '{0}'. + internal const string InvalidXmlBaseAttributePresent = nameof(InvalidXmlBaseAttributePresent); + /// '{0}' ID conflicts with the ID of an existing relationship for the specified source. + internal const string NotAUniqueRelationshipId = nameof(NotAUniqueRelationshipId); + /// '{0}' ID is not a valid XSD ID. + internal const string NotAValidXmlIdString = nameof(NotAValidXmlIdString); + /// '{0}' attribute value is not valid. + internal const string InvalidValueForTheAttribute = nameof(InvalidValueForTheAttribute); + /// Relationship Type cannot contain only spaces or be empty. + internal const string InvalidRelationshipType = nameof(InvalidRelationshipType); + /// Part URI must start with a forward slash. + internal const string PartUriShouldStartWithForwardSlash = nameof(PartUriShouldStartWithForwardSlash); + /// Part URI cannot end with a forward slash. + internal const string PartUriShouldNotEndWithForwardSlash = nameof(PartUriShouldNotEndWithForwardSlash); + /// URI must contain pack:// scheme. + internal const string UriShouldBePackScheme = nameof(UriShouldBePackScheme); + /// Part URI is empty. + internal const string PartUriIsEmpty = nameof(PartUriIsEmpty); + /// Part URI is not valid per rules defined in the Open Packaging Conventions specification. + internal const string InvalidPartUri = nameof(InvalidPartUri); + /// PackageRelationship part URI is not expected. + internal const string RelationshipPartUriNotExpected = nameof(RelationshipPartUriNotExpected); + /// PackageRelationship part URI is expected. + internal const string RelationshipPartUriExpected = nameof(RelationshipPartUriExpected); + /// PackageRelationship part URI syntax is not valid. + internal const string NotAValidRelationshipPartUri = nameof(NotAValidRelationshipPartUri); + /// The 'fragment' parameter must start with a number sign. + internal const string FragmentMustStartWithHash = nameof(FragmentMustStartWithHash); + /// Part URI cannot contain a Fragment component. + internal const string PartUriCannotHaveAFragment = nameof(PartUriCannotHaveAFragment); + /// Part URI cannot start with two forward slashes. + internal const string PartUriShouldNotStartWithTwoForwardSlashes = nameof(PartUriShouldNotStartWithTwoForwardSlashes); + /// Package URI obtained from the pack URI cannot contain a Fragment. + internal const string InnerPackageUriHasFragment = nameof(InnerPackageUriHasFragment); + /// Cannot access Stream object because it was closed or disposed. + internal const string StreamObjectDisposed = nameof(StreamObjectDisposed); + /// GetContentTypeCore method cannot return null for the content type stream. + internal const string NullContentTypeProvided = nameof(NullContentTypeProvided); + /// PackagePart subclass must implement GetContentTypeCore method if passing a null value for the content type when PackagePart object is constructed. + internal const string GetContentTypeCoreNotImplemented = nameof(GetContentTypeCoreNotImplemented); + /// '{0}' tag requires attribute '{1}'. + internal const string RequiredAttributeMissing = nameof(RequiredAttributeMissing); + /// '{0}' tag requires a nonempty '{1}' attribute. + internal const string RequiredAttributeEmpty = nameof(RequiredAttributeEmpty); + /// Types tag has attributes not valid per the schema. + internal const string TypesTagHasExtraAttributes = nameof(TypesTagHasExtraAttributes); + /// Required Types tag not found. + internal const string TypesElementExpected = nameof(TypesElementExpected); + /// Content Types XML does not match schema. + internal const string TypesXmlDoesNotMatchSchema = nameof(TypesXmlDoesNotMatchSchema); + /// Default tag is not valid per the schema. Verify that attributes are correct. + internal const string DefaultTagDoesNotMatchSchema = nameof(DefaultTagDoesNotMatchSchema); + /// Override tag is not valid per the schema. Verify that attributes are correct. + internal const string OverrideTagDoesNotMatchSchema = nameof(OverrideTagDoesNotMatchSchema); + /// '{0}' element must be empty. + internal const string ElementIsNotEmptyElement = nameof(ElementIsNotEmptyElement); + /// Format error in package. + internal const string BadPackageFormat = nameof(BadPackageFormat); + /// Streaming mode is supported only for creating packages. + internal const string StreamingModeNotSupportedForConsumption = nameof(StreamingModeNotSupportedForConsumption); + /// Must have write-only access to produce a package in streaming mode. + internal const string StreamingPackageProductionImpliesWriteOnlyAccess = nameof(StreamingPackageProductionImpliesWriteOnlyAccess); + /// Cannot have concurrent write accesses on package being produced in streaming mode. + internal const string StreamingPackageProductionRequiresSingleWriter = nameof(StreamingPackageProductionRequiresSingleWriter); + /// '{0}' method can only be called on a package opened in streaming mode. + internal const string MethodAvailableOnlyInStreamingCreation = nameof(MethodAvailableOnlyInStreamingCreation); + /// Package.{0} is not supported in streaming production. + internal const string OperationIsNotSupportedInStreamingProduction = nameof(OperationIsNotSupportedInStreamingProduction); + /// Only write operations are supported in streaming production. + internal const string OnlyWriteOperationsAreSupportedInStreamingCreation = nameof(OnlyWriteOperationsAreSupportedInStreamingCreation); + /// Write-once semantics in streaming production precludes the use of '{0}'. + internal const string OperationViolatesWriteOnceSemantics = nameof(OperationViolatesWriteOnceSemantics); + /// Streaming consumption of packages not supported. + internal const string OnlyStreamingProductionIsSupported = nameof(OnlyStreamingProductionIsSupported); + /// Read or write operation references location outside the bounds of the buffer provided. + internal const string IOBufferOverflow = nameof(IOBufferOverflow); + /// Cannot change content of a read-only stream. + internal const string StreamDoesNotSupportWrite = nameof(StreamDoesNotSupportWrite); + /// Package has more than one Core Properties relationship. + internal const string MoreThanOneMetadataRelationships = nameof(MoreThanOneMetadataRelationships); + /// TargetMode for a Core Properties relationship must be 'Internal'. + internal const string NoExternalTargetForMetadataRelationship = nameof(NoExternalTargetForMetadataRelationship); + /// Unrecognized root element in Core Properties part. + internal const string CorePropertiesElementExpected = nameof(CorePropertiesElementExpected); + /// Core Properties part: core property elements can contain only text data. + internal const string NoStructuredContentInsideProperties = nameof(NoStructuredContentInsideProperties); + /// Unrecognized namespace in Core Properties part. + internal const string UnknownNamespaceInCorePropertiesPart = nameof(UnknownNamespaceInCorePropertiesPart); + /// '{0}' property name is not valid in Core Properties part. + internal const string InvalidPropertyNameInCorePropertiesPart = nameof(InvalidPropertyNameInCorePropertiesPart); + /// Core Properties part: A property start-tag was expected. + internal const string PropertyStartTagExpected = nameof(PropertyStartTagExpected); + /// Core Properties part: Text data of XSD type 'DateTime' was expected. + internal const string XsdDateTimeExpected = nameof(XsdDateTimeExpected); + /// The target of the Core Properties relationship does not reference an existing part. + internal const string DanglingMetadataRelationship = nameof(DanglingMetadataRelationship); + /// The Core Properties relationship references a part that has an incorrect content type. + internal const string WrongContentTypeForPropertyPart = nameof(WrongContentTypeForPropertyPart); + /// Unexpected number of attributes is found on '{0}'. + internal const string PropertyWrongNumbOfAttribsDefinedOn = nameof(PropertyWrongNumbOfAttribsDefinedOn); + /// Unknown xsi:type for DateTime on '{0}'. + internal const string UnknownDCDateTimeXsiType = nameof(UnknownDCDateTimeXsiType); + /// More than one '{0}' property found. + internal const string DuplicateCorePropertyName = nameof(DuplicateCorePropertyName); + /// PackageProperties object was disposed. + internal const string StorageBasedPackagePropertiesDiposed = nameof(StorageBasedPackagePropertiesDiposed); + /// Encoding format is not supported. Only UTF-8 and UTF-16 are supported. + internal const string EncodingNotSupported = nameof(EncodingNotSupported); + /// Duplicate pieces found in the package. + internal const string DuplicatePiecesFound = nameof(DuplicatePiecesFound); + /// Cannot find piece with the specified piece number. + internal const string PieceDoesNotExist = nameof(PieceDoesNotExist); + /// This serviceType is already registered to another service. + internal const string ServiceTypeAlreadyAdded = nameof(ServiceTypeAlreadyAdded); + /// '{0}' type name does not have the expected format 'className, assembly'. + internal const string QualifiedNameHasWrongFormat = nameof(QualifiedNameHasWrongFormat); + /// Too many attributes are specified for '{0}'. + internal const string ParserAttributeArgsHigh = nameof(ParserAttributeArgsHigh); + /// '{0}' requires more attributes. + internal const string ParserAttributeArgsLow = nameof(ParserAttributeArgsLow); + /// Cannot load assembly '{0}' because a different version of that same assembly is loaded '{1}'. + internal const string ParserAssemblyLoadVersionMismatch = nameof(ParserAssemblyLoadVersionMismatch); + /// (null) + internal const string ToStringNull = nameof(ToStringNull); + /// '{0}' ValueSerializer cannot convert '{1}' to '{2}'. + internal const string ConvertToException = nameof(ConvertToException); + /// '{0}' ValueSerializer cannot convert from '{1}'. + internal const string ConvertFromException = nameof(ConvertFromException); + /// SortDescription must have a nonempty property name. + internal const string SortDescriptionPropertyNameCannotBeEmpty = nameof(SortDescriptionPropertyNameCannotBeEmpty); + /// Cannot modify a '{0}' after it is sealed. + internal const string CannotChangeAfterSealed = nameof(CannotChangeAfterSealed); + /// Cannot group by property '{0}' because it cannot be found on type '{1}'. + internal const string BadPropertyForGroup = nameof(BadPropertyForGroup); + /// The CollectionView that originates this CurrentChanging event is in a state that does not allow the event to be canceled. Check CurrentChangingEventArgs.IsCancelable before assigning to this CurrentChangingEventArgs.Cancel property. + internal const string CurrentChangingCannotBeCanceled = nameof(CurrentChangingCannotBeCanceled); + /// Collection is read-only. + internal const string NotSupported_ReadOnlyCollection = nameof(NotSupported_ReadOnlyCollection); + /// Only single dimensional arrays are supported for the requested action. + internal const string Arg_RankMultiDimNotSupported = nameof(Arg_RankMultiDimNotSupported); + /// The lower bound of target array must be zero. + internal const string Arg_NonZeroLowerBound = nameof(Arg_NonZeroLowerBound); + /// Non-negative number required. + internal const string ArgumentOutOfRange_NeedNonNegNum = nameof(ArgumentOutOfRange_NeedNonNegNum); + /// Destination array is not long enough to copy all the items in the collection. Check array index and length. + internal const string Arg_ArrayPlusOffTooSmall = nameof(Arg_ArrayPlusOffTooSmall); + /// Target array type is not compatible with the type of items in the collection. + internal const string Argument_InvalidArrayType = nameof(Argument_InvalidArrayType); + /// '{0}' index is beyond maximum '{1}'. + internal const string ReachOutOfRange = nameof(ReachOutOfRange); + /// Permission state is not valid. + internal const string InvalidPermissionState = nameof(InvalidPermissionState); + /// Target is not a WebBrowserPermission. + internal const string TargetNotWebBrowserPermissionLevel = nameof(TargetNotWebBrowserPermissionLevel); + /// Target is not a MediaPermission. + internal const string TargetNotMediaPermissionLevel = nameof(TargetNotMediaPermissionLevel); + /// '{0}' attribute is not valid XML. + internal const string BadXml = nameof(BadXml); + /// Permission level is not valid. + internal const string InvalidPermissionLevel = nameof(InvalidPermissionLevel); + /// Choice is valid only in AlternateContent. + internal const string XCRChoiceOnlyInAC = nameof(XCRChoiceOnlyInAC); + /// Choice cannot follow a Fallback. + internal const string XCRChoiceAfterFallback = nameof(XCRChoiceAfterFallback); + /// Choice must contain Requires attribute. + internal const string XCRRequiresAttribNotFound = nameof(XCRRequiresAttribNotFound); + /// Requires attribute must contain a valid namespace prefix. + internal const string XCRInvalidRequiresAttribute = nameof(XCRInvalidRequiresAttribute); + /// Fallback is valid only in AlternateContent. + internal const string XCRFallbackOnlyInAC = nameof(XCRFallbackOnlyInAC); + /// AlternateContent must contain one or more Choice elements. + internal const string XCRChoiceNotFound = nameof(XCRChoiceNotFound); + /// AlternateContent must contain only one Fallback element. + internal const string XCRMultipleFallbackFound = nameof(XCRMultipleFallbackFound); + /// '{0}' attribute is not valid for '{1}' element. + internal const string XCRInvalidAttribInElement = nameof(XCRInvalidAttribInElement); + /// Unrecognized Compatibility element '{0}'. + internal const string XCRUnknownCompatElement = nameof(XCRUnknownCompatElement); + /// '{0}' element is not a valid child of AlternateContent. Only Choice and Fallback elements are valid children of an AlternateContent element. + internal const string XCRInvalidACChild = nameof(XCRInvalidACChild); + /// '{0}' format is not valid. + internal const string XCRInvalidFormat = nameof(XCRInvalidFormat); + /// '{0}' prefix is not defined. + internal const string XCRUndefinedPrefix = nameof(XCRUndefinedPrefix); + /// Unrecognized compatibility attribute '{0}'. + internal const string XCRUnknownCompatAttrib = nameof(XCRUnknownCompatAttrib); + /// '{0}' namespace cannot process content; it must be declared Ignorable first. + internal const string XCRNSProcessContentNotIgnorable = nameof(XCRNSProcessContentNotIgnorable); + /// Duplicate ProcessContent declaration for element '{1}' in namespace '{0}'. + internal const string XCRDuplicateProcessContent = nameof(XCRDuplicateProcessContent); + /// Cannot have both a specific and a wildcard ProcessContent declaration for namespace '{0}'. + internal const string XCRInvalidProcessContent = nameof(XCRInvalidProcessContent); + /// Duplicate wildcard ProcessContent declaration for namespace '{0}'. + internal const string XCRDuplicateWildcardProcessContent = nameof(XCRDuplicateWildcardProcessContent); + /// MustUnderstand condition failed on namespace '{0}' + internal const string XCRMustUnderstandFailed = nameof(XCRMustUnderstandFailed); + /// '{0}' namespace cannot preserve items; it must be declared Ignorable first. + internal const string XCRNSPreserveNotIgnorable = nameof(XCRNSPreserveNotIgnorable); + /// Duplicate Preserve declaration for element {1} in namespace '{0}'. + internal const string XCRDuplicatePreserve = nameof(XCRDuplicatePreserve); + /// Cannot have both a specific and a wildcard Preserve declaration for namespace '{0}'. + internal const string XCRInvalidPreserve = nameof(XCRInvalidPreserve); + /// Duplicate wildcard Preserve declaration for namespace '{0}'. + internal const string XCRDuplicateWildcardPreserve = nameof(XCRDuplicateWildcardPreserve); + /// '{0}' attribute value is not a valid XML name. + internal const string XCRInvalidXMLName = nameof(XCRInvalidXMLName); + /// There is a cycle of XML compatibility definitions, such that namespace '{0}' overrides itself. This could be due to inconsistent XmlnsCompatibilityAttributes in different assemblies. Please change the definitions to eliminate this cycle. + internal const string XCRCompatCycle = nameof(XCRCompatCycle); + /// '{1}' event not found on type '{0}'. + internal const string EventNotFound = nameof(EventNotFound); + /// Listener did not handle requested event. + internal const string ListenerDidNotHandleEvent = nameof(ListenerDidNotHandleEvent); + /// Listener of type '{0}' registered with event manager of type '{1}', but then did not handle the event. The listener is coded incorrectly. + internal const string ListenerDidNotHandleEventDetail = nameof(ListenerDidNotHandleEventDetail); + /// WeakEventManager supports only delegates with one target. + internal const string NoMulticastHandlers = nameof(NoMulticastHandlers); + /// Unrecoverable system error. + internal const string InvariantFailure = nameof(InvariantFailure); + /// ContentType string cannot have leading/trailing Linear White Spaces [LWS - RFC 2616]. + internal const string ContentTypeCannotHaveLeadingTrailingLWS = nameof(ContentTypeCannotHaveLeadingTrailingLWS); + /// ContentType string is not valid. Expected format is type/subtype. + internal const string InvalidTypeSubType = nameof(InvalidTypeSubType); + /// ';' must be followed by parameter=value pair. + internal const string ExpectingParameterValuePairs = nameof(ExpectingParameterValuePairs); + /// Parameter and value pair is not valid. Expected form is parameter=value. + internal const string InvalidParameterValuePair = nameof(InvalidParameterValuePair); + /// A token is not valid. Refer to RFC 2616 for correct grammar of content types. + internal const string InvalidToken = nameof(InvalidToken); + /// Parameter value must be a valid token or a quoted string as per RFC 2616. + internal const string InvalidParameterValue = nameof(InvalidParameterValue); + /// A Linear White Space character is not valid. + internal const string InvalidLinearWhiteSpaceCharacter = nameof(InvalidLinearWhiteSpaceCharacter); + /// Semicolon separator is required between two valid parameter=value pairs. + internal const string ExpectingSemicolon = nameof(ExpectingSemicolon); + /// HwndSubclass.Attach has already been called; it cannot be called again. + internal const string HwndSubclassMultipleAttach = nameof(HwndSubclassMultipleAttach); + /// Cannot locate resource '{0}'. + internal const string UnableToLocateResource = nameof(UnableToLocateResource); + /// Please wait while the application opens + internal const string SplashScreenIsLoading = nameof(SplashScreenIsLoading); + /// Name cannot be an empty string. + internal const string NameScopeNameNotEmptyString = nameof(NameScopeNameNotEmptyString); + /// '{0}' Name is not found. + internal const string NameScopeNameNotFound = nameof(NameScopeNameNotFound); + /// Cannot register duplicate Name '{0}' in this scope. + internal const string NameScopeDuplicateNamesNotAllowed = nameof(NameScopeDuplicateNamesNotAllowed); + /// No NameScope found to {1} the Name '{0}'. + internal const string NameScopeNotFound = nameof(NameScopeNotFound); + /// '{0}' name is not valid for identifier. + internal const string NameScopeInvalidIdentifierName = nameof(NameScopeInvalidIdentifierName); + /// No dependency property {0} on {1}. + internal const string NoDependencyProperty = nameof(NoDependencyProperty); + /// Must set ArrayType before calling ProvideValue on ArrayExtension. + internal const string MarkupExtensionArrayType = nameof(MarkupExtensionArrayType); + /// Items in the array must be type '{0}'. One or more items cannot be cast to this type. + internal const string MarkupExtensionArrayBadType = nameof(MarkupExtensionArrayBadType); + /// Markup extension '{0}' requires '{1}' be implemented in the IServiceProvider for ProvideValue. + internal const string MarkupExtensionNoContext = nameof(MarkupExtensionNoContext); + /// '{0}' StaticExtension value cannot be resolved to an enumeration, static field, or static property. + internal const string MarkupExtensionBadStatic = nameof(MarkupExtensionBadStatic); + /// StaticExtension must have Member property set before ProvideValue can be called. + internal const string MarkupExtensionStaticMember = nameof(MarkupExtensionStaticMember); + /// TypeExtension must have TypeName property set before ProvideValue can be called. + internal const string MarkupExtensionTypeName = nameof(MarkupExtensionTypeName); + /// '{0}' string is not valid for type. + internal const string MarkupExtensionTypeNameBad = nameof(MarkupExtensionTypeNameBad); + /// '{0}' must be of type '{1}'. + internal const string MustBeOfType = nameof(MustBeOfType); + /// This operation requires the thread's apartment state to be '{0}'. + internal const string Verify_ApartmentState = nameof(Verify_ApartmentState); + /// The argument can neither be null nor empty. + internal const string Verify_NeitherNullNorEmpty = nameof(Verify_NeitherNullNorEmpty); + /// The argument can not be equal to '{0}'. + internal const string Verify_AreNotEqual = nameof(Verify_AreNotEqual); + /// No file exists at '{0}'. + internal const string Verify_FileExists = nameof(Verify_FileExists); + /// Event argument is invalid. + internal const string InvalidEvent = nameof(InvalidEvent); + /// The property '{0}' cannot be changed. The '{1}' class has been sealed. + internal const string CompatibilityPreferencesSealed = nameof(CompatibilityPreferencesSealed); + /// Desktop applications are required to opt in to all earlier accessibility improvements to get the later improvements. To do this, ensure that if the AppContext switch 'Switch.UseLegacyAccessibilityFeatures.N' is set to 'false', then 'Switch.UseLegacyAccessi ... + internal const string CombinationOfAccessibilitySwitchesNotSupported = nameof(CombinationOfAccessibilitySwitchesNotSupported); + /// Desktop applications setting AppContext switch '{0}' to false are required to opt in to all earlier accessibility improvements. To do this, ensure that the AppContext switch '{1}' is set to 'false', then 'Switch.UseLegacyAccessibilityFeatures' and all 'Swi ... + internal const string AccessibilitySwitchDependencyNotSatisfied = nameof(AccessibilitySwitchDependencyNotSatisfied); + /// Extra data encountered at position {0} while parsing '{1}'. + internal const string TokenizerHelperExtraDataEncountered = nameof(TokenizerHelperExtraDataEncountered); + /// Premature string termination encountered while parsing '{0}'. + internal const string TokenizerHelperPrematureStringTermination = nameof(TokenizerHelperPrematureStringTermination); + /// Missing end quote encountered while parsing '{0}'. + internal const string TokenizerHelperMissingEndQuote = nameof(TokenizerHelperMissingEndQuote); + /// Empty token encountered at position {0} while parsing '{1}'. + internal const string TokenizerHelperEmptyToken = nameof(TokenizerHelperEmptyToken); + /// No current object to return. + internal const string Enumerator_VerifyContext = nameof(Enumerator_VerifyContext); + /// PermissionState value '{0}' is not valid for this Permission. + internal const string InvalidPermissionStateValue = nameof(InvalidPermissionStateValue); + /// Permission type is not valid. Expected '{0}'. + internal const string InvalidPermissionType = nameof(InvalidPermissionType); + /// Parameter cannot be a zero-length string. + internal const string StringEmpty = nameof(StringEmpty); + /// Parameter must be greater than or equal to zero. + internal const string ParameterCannotBeNegative = nameof(ParameterCannotBeNegative); + /// Specified value of type '{0}' must have IsFrozen set to false to modify. + internal const string Freezable_CantBeFrozen = nameof(Freezable_CantBeFrozen); + /// Cannot change property metadata after it has been associated with a property. + internal const string TypeMetadataCannotChangeAfterUse = nameof(TypeMetadataCannotChangeAfterUse); + /// '{0}' enumeration value is not valid. + internal const string Enum_Invalid = nameof(Enum_Invalid); + /// Cannot convert string value '{0}' to type '{1}'. + internal const string CannotConvertStringToType = nameof(CannotConvertStringToType); + /// Cannot modify a read-only container. + internal const string CannotModifyReadOnlyContainer = nameof(CannotModifyReadOnlyContainer); + /// Cannot get part or part information from a write-only container. + internal const string CannotRetrievePartsOfWriteOnlyContainer = nameof(CannotRetrievePartsOfWriteOnlyContainer); + /// '{0}' file does not conform to the expected file format specification. + internal const string FileFormatExceptionWithFileName = nameof(FileFormatExceptionWithFileName); + /// Input file or data stream does not conform to the expected file format specification. + internal const string FileFormatException = nameof(FileFormatException); + /// {0} is an invalid handle. + internal const string Cryptography_InvalidHandle = nameof(Cryptography_InvalidHandle); + /// DLL Name: {0} DLL Location: {1} + internal const string WpfDllConsistencyErrorData = nameof(WpfDllConsistencyErrorData); + /// Failed WPF DLL consistency checks. Expected location: {0}. + internal const string WpfDllConsistencyErrorHeader = nameof(WpfDllConsistencyErrorHeader); + + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/ref/SR.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/ref/SR.cs new file mode 100644 index 0000000..9e81ad7 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.InkCore/WpfInkRenderAlgorithms/ref/SR.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace System; + +internal static partial class SR +{ + public static string? Size_CannotModifyEmptySize; + public static string? Rect_CannotCallMethod; + public static string? Size_HeightCannotBeNegative; + public static string? Size_WidthCannotBeNegative; + public static string? Rect_CannotModifyEmptyRect; + public static string? Color_NullColorContext; + public static string? Color_DimensionMismatch; + public static string? Stylus_InvalidMax; + public static Exception? InvalidStylusPointXYNaN; + public static string? Size_WidthAndHeightCannotBeNegative; + public static string? InvalidStylusPointDescription { get; set; } + public static string? InvalidStylusPointDescriptionDuplicatesFound { get; set; } + public static Exception? InvalidPressureValue { get; set; } + public static string? InvalidAdditionalDataForStylusPoint { get; set; } + public static string? InvalidStylusPointProperty { get; set; } + public static Exception? InvalidMinMaxForButton { get; set; } + public static string? InvalidStylusPointConstructionZeroLengthCollection { get; set; } + public static string? IncompatibleStylusPointDescriptions { get; set; } + public static string? InvalidStylusPointCollectionZeroCount { get; set; } + public static string? InvalidStylusPointDescriptionButtonsMustBeLast { get; set; } + public static string? InvalidStylusPointDescriptionTooManyButtons { get; set; } + public static string? InvalidIsButtonForId { get; set; } + public static string? InvalidIsButtonForId2 { get; set; } + public static string? InvalidStylusPointPropertyInfoResolution { get; set; } + + public static string Get(string invalidGuid, params object[] p) + { + return string.Empty; + } + + public static string? Format(string? a, params object[] p) + { + return a; + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/AssemblyInfo.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/AssemblyInfo.cs new file mode 100644 index 0000000..39be099 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DotNetCampus.InkCanvas.X11InkCanvas")] +[assembly: InternalsVisibleTo("DotNetCampus.AvaloniaInkCanvas")] \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Context_/SkiaStrokeSynchronizer.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Context_/SkiaStrokeSynchronizer.cs new file mode 100644 index 0000000..d049b66 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Context_/SkiaStrokeSynchronizer.cs @@ -0,0 +1,24 @@ +using SkiaSharp; +using InkStylusPoint = DotNetCampus.Inking.Primitive.InkStylusPoint; + +namespace DotNetCampus.Inking; + +/// +/// 笔迹信息 用于静态笔迹层 +/// +record SkiaStrokeSynchronizer( + uint StylusDeviceId, + InkId InkId, + SKColor StrokeColor, + double StrokeInkThickness, + SKPath? InkStrokePath, + List StylusPoints)// : InkSynchronizer.StrokeSynchronizer(StylusDeviceId) +{ + public SKRect GetBounds() + { + return _bounds ??= InkStrokePath?.Bounds ?? SKRect.Empty; + } + + private SKRect? _bounds; +} +; diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/DotNetCampus.InkCanvas.SkiaInk.csproj b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/DotNetCampus.InkCanvas.SkiaInk.csproj new file mode 100644 index 0000000..d598af6 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/DotNetCampus.InkCanvas.SkiaInk.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + DotNetCampus.Inking + true + true + + + + + + + + + + + diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/DotNetCampus.InkCanvas.SkiaInk.csproj.DotSettings b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/DotNetCampus.InkCanvas.SkiaInk.csproj.DotSettings new file mode 100644 index 0000000..8616cc0 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/DotNetCampus.InkCanvas.SkiaInk.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/EraserView.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/EraserView.cs new file mode 100644 index 0000000..530073d --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/EraserView.cs @@ -0,0 +1,51 @@ +using SkiaSharp; + +namespace DotNetCampus.Inking; + +class EraserView +{ + public SKBitmap GetEraserView(int width, int height) + { + var skBitmap = new SKBitmap(new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul)); + + using var skCanvas = new SKCanvas(skBitmap); + DrawEraserView(skCanvas, width, height); + + return skBitmap; + } + + public void DrawEraserView(SKCanvas skCanvas, float width, float height) + { + var pathWidth = 30; + var pathHeight = 45; + + bool needScale = Math.Abs(width - pathWidth) > 0.001 || Math.Abs(height - pathHeight) > 0.001; + + if (needScale) + { + skCanvas.Save(); + skCanvas.Scale(width / pathWidth, height / pathHeight); + } + + using var path1 = SKPath.ParseSvgPathData( + "M0,5.0093855C0,2.24277828,2.2303666,0,5.00443555,0L24.9955644,0C27.7594379,0,30,2.23861485,30,4.99982044L30,17.9121669C30,20.6734914,30,25.1514578,30,27.9102984L30,40.0016889C30,42.7621799,27.7696334,45,24.9955644,45L5.00443555,45C2.24056212,45,0,42.768443,0,39.9906145L0,5.0093855z"); + using var skPaint = new SKPaint(); + skPaint.IsAntialias = true; + skPaint.Style = SKPaintStyle.Fill; + skPaint.Color = new SKColor(0, 0, 0, 0x33); + skCanvas.DrawPath(path1, skPaint); + + skPaint.Color = new SKColor(0xF2, 0xEE, 0xEB, 0xFF); + skCanvas.DrawRoundRect(1, 1, 28, 43, 4, 4, skPaint); + + using var path2 = SKPath.ParseSvgPathData( + "M20,29.1666667L20,16.1666667C20,15.3382395 19.3284271,14.6666667 18.5,14.6666667 17.6715729,14.6666667 17,15.3382395 17,16.1666667L17,29.1666667C17,29.9950938 17.6715729,30.6666667 18.5,30.6666667 19.3284271,30.6666667 20,29.9950938 20,29.1666667z M13,29.1666667L13,16.1666667C13,15.3382395 12.3284271,14.6666667 11.5,14.6666667 10.6715729,14.6666667 10,15.3382395 10,16.1666667L10,29.1666667C10,29.9950938 10.6715729,30.6666667 11.5,30.6666667 12.3284271,30.6666667 13,29.9950938 13,29.1666667z"); + skPaint.Color = new SKColor(0x00, 0x00, 0x00, 0x26); + skCanvas.DrawPath(path2, skPaint); + + if (needScale) + { + skCanvas.Restore(); + } + } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Erasing/ErasingCompletedEventArgs.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Erasing/ErasingCompletedEventArgs.cs new file mode 100644 index 0000000..135ffcc --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Erasing/ErasingCompletedEventArgs.cs @@ -0,0 +1,28 @@ +namespace DotNetCampus.Inking.Erasing; + +/// +/// 擦除完成参数 +/// +class SkInkCanvasErasingCompletedEventArgs : EventArgs +{ + public SkInkCanvasErasingCompletedEventArgs(bool isCanceled) + { + IsCanceled = isCanceled; + } + + public SkInkCanvasErasingCompletedEventArgs(IReadOnlyList originList, + IReadOnlyList newList) : this(false) + { + OriginList = originList; + NewList = newList; + } + + public IReadOnlyList OriginList { get; } = null!; + + public IReadOnlyList NewList { get; } = null!; + + /// + /// 是否取消 + /// + public bool IsCanceled { get; private set; } +} \ No newline at end of file diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Interactives/SkInkCanvasManipulationManager.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Interactives/SkInkCanvasManipulationManager.cs new file mode 100644 index 0000000..c1b955c --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Interactives/SkInkCanvasManipulationManager.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace DotNetCampus.Inking.Interactives; + +enum InputMode +{ + Ink, + Manipulate, +} + + +internal class SkInkCanvasManipulationManager +{ +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Primitive/RectExtension.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Primitive/RectExtension.cs new file mode 100644 index 0000000..1235256 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Primitive/RectExtension.cs @@ -0,0 +1,118 @@ +using SkiaSharp; +using Rect = DotNetCampus.Numerics.Geometry.Rect2D; + +namespace DotNetCampus.Inking.Primitive; + +static class RectExtension +{ + public static SKRectI LimitRect(SKRectI inputRect, SKRectI maxRect) + { + var left = inputRect.Left; + var top = inputRect.Top; + var right = inputRect.Right; + var bottom = inputRect.Bottom; + + left = Math.Max(left, maxRect.Left); + top = Math.Max(top, maxRect.Top); + right = Math.Min(right, maxRect.Right); + bottom = Math.Min(bottom, maxRect.Bottom); + + var width = right - left; + var height = bottom - top; + + if (width <= 0 || height <= 0) + { + return SKRectI.Empty; + } + + return SKRectI.Create(left, top, width, height); + } + + public static SKRect LimitRect(SKRect inputRect, SKRect maxRect) + { + var left = inputRect.Left; + var top = inputRect.Top; + var right = inputRect.Right; + var bottom = inputRect.Bottom; + + left = Math.Max(left, maxRect.Left); + top = Math.Max(top, maxRect.Top); + right = Math.Min(right, maxRect.Right); + bottom = Math.Min(bottom, maxRect.Bottom); + + var width = right - left; + var height = bottom - top; + + if (width <= 0 || height <= 0) + { + return SKRect.Empty; + } + + return SKRect.Create(left, top, width, height); + } + + public static Rect LimitRect(Rect inputRect, Rect maxRect) + { + var left = inputRect.Left; + var top = inputRect.Top; + var right = inputRect.Right; + var bottom = inputRect.Bottom; + + if (double.IsNaN(left) || double.IsNaN(top) || double.IsNaN(right) || double.IsNaN(bottom)) + { + return Rect.Zero; + } + + left = Math.Max(left, maxRect.Left); + top = Math.Max(top, maxRect.Top); + right = Math.Min(right, maxRect.Right); + bottom = Math.Min(bottom, maxRect.Bottom); + + var width = right - left; + var height = bottom - top; + + if (width <= 0 || height <= 0) + { + return Rect.Zero; + } + + return new Rect(left, top, width, height); + } + + public static Rect ExpandLength(SKRect rect, double additionLengthIncrement) + { + return new Rect(rect.Left - additionLengthIncrement, rect.Top - additionLengthIncrement, + rect.Width + additionLengthIncrement * 2, rect.Height + additionLengthIncrement * 2); + } + + public static SKRect ExpandSKRectLength(SKRect rect, float additionLengthIncrement) + { + return new SKRect(rect.Left - additionLengthIncrement, rect.Top - additionLengthIncrement, + rect.Width + additionLengthIncrement * 2, rect.Height + additionLengthIncrement * 2); + } + + //public static Rect ToMauiRect(this SKRect rect) + //{ + // return new Rect(rect.Left, rect.Top, rect.Width, rect.Height); + //} + + //public static Rect ToMauiRect(this Rect rect) + //{ + // return new Rect(rect.Left, rect.Top, rect.Width, rect.Height); + //} + + public static Rect ToRect2D(this SKRect rect) + { + return new Rect(rect.Left, rect.Top, rect.Width, rect.Height); + } + + public static Rect ToRect2D(this Rect rect) + { + return new Rect(rect.Left, rect.Top, rect.Width, rect.Height); + } + + public static SKRect ToSkRect(this Rect rect) + { + return new SKRect((float) rect.Left, (float) rect.Top, (float) rect.Right, (float) rect.Bottom); + } +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Settings/CleanStrokeSettings.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Settings/CleanStrokeSettings.cs new file mode 100644 index 0000000..4ce710a --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Settings/CleanStrokeSettings.cs @@ -0,0 +1,17 @@ +namespace DotNetCampus.Inking.Settings; + +/// +/// 对清空笔迹的配置 +/// +record CleanStrokeSettings +{ + /// + /// 清空笔迹之后,需要绘制背景图。对于一些背景是没有任何内容的应用,则不需要绘制,提升性能。因为清空笔迹之后,会将当前的静态笔迹都绘制一次,除非背景有图片或其他内容,否则不需要绘制背景 + /// + public bool ShouldDrawBackground { get; init; } = false; + + /// + /// 清空笔迹之后,是否需要更新背景图。解决当前有两个笔迹,只删除其中一个笔迹,如果此时背景没有更新,则可能导致两个笔迹都被删除,或被删除的笔迹依然在背景里 + /// + public bool ShouldUpdateBackground { get; init; } = true; +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Settings/DropPointSettings.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Settings/DropPointSettings.cs new file mode 100644 index 0000000..367e1c3 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Settings/DropPointSettings.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace DotNetCampus.Inking.Settings; + +/// +/// 丢点配置 +/// +record DropPointSettings +{ + /// + /// 最大丢点数量 + /// + public int MaxDropPointCount { get; init; } = 10; + + public int MaxDistanceLength { get; init; } = 2; + + /// + /// 丢点策略 + /// + public DropPointStrategy DropPointStrategy { get; init; } = DropPointStrategy.Normal; +} + +/// +/// 丢点策略 +/// +public enum DropPointStrategy +{ + /// + /// 普通的丢点,不会丢太多 + /// + Normal, + + /// + /// 激进策略,会丢很多点 + /// + Aggressive, +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Settings/InkCanvasDynamicRenderTipStrokeType.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Settings/InkCanvasDynamicRenderTipStrokeType.cs new file mode 100644 index 0000000..6c86516 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Settings/InkCanvasDynamicRenderTipStrokeType.cs @@ -0,0 +1,28 @@ +using DotNetCampus.Inking.Utils; + +namespace DotNetCampus.Inking.Settings; + +/// +/// 笔尖渲染模式 +/// +enum InkCanvasDynamicRenderTipStrokeType +{ + /// + /// 通过裁剪画布的方式进行绘制所有的笔迹 + /// + /// 这是当前最快的笔迹,写的快炸的快 + /// 这里面用了绕过 Skia 的裁剪,使用 替换为背景 + RenderAllTouchingStrokeWithClip, + + /// + /// 所有触摸按下的笔迹都每次重新绘制,不区分笔尖和笔身 + /// 此方式可以实现比较好的平滑效果 + /// + /// 此方式性能比较差,但是最符合预期的 + RenderAllTouchingStrokeWithoutTipStroke, + + /// + /// 只渲染笔尖部分 + /// + RenderTipStrokeOnly, +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Settings/InkCanvasEraserAlgorithmMode.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Settings/InkCanvasEraserAlgorithmMode.cs new file mode 100644 index 0000000..36310b5 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Settings/InkCanvasEraserAlgorithmMode.cs @@ -0,0 +1,31 @@ +namespace DotNetCampus.Inking.Settings; + +/// +/// 橡皮擦算法模式 +/// +enum InkCanvasEraserAlgorithmMode +{ + /// + /// 是否允许使用裁剪方式的橡皮擦,而不是走静态笔迹层。使用裁剪而不是使用笔迹计算,将笔迹的点给去掉 + /// + EnableClippingEraser, + + /// + /// 是否允许使用裁剪方式的橡皮擦,橡皮擦每次裁剪都写入画布,需要有多余的画布拷贝逻辑,但是不需要做 Path 处理。原理同 但是具体的裁剪逻辑不相同。用来减少擦除时间长时的越擦越卡的问题 + /// 此模式不支持漫游画布和切页。因为漫游画布和切页需要 Path 处理,而此模式没有进行 Path 处理,只是进行位图处理 + /// + /// 非路径 Path 的点擦情况下,当前最优的橡皮擦算法 + EnableClippingEraserWithoutEraserPathCombine, + + /// + /// 是否允许使用裁剪方式的橡皮擦,带不安全模式的二进制,橡皮擦每次裁剪都写入画布,需要有多余的画布拷贝逻辑,但是不需要做 Path 处理。原理同 但是具体的裁剪逻辑不相同。用来减少擦除时间长时的越擦越卡的问题。带不安全的二进制处理可以提升画图片的性能 + /// + /// 尚未全部完成 + EnableClippingEraserWithBinaryWithoutEraserPathCombine, + + /// + /// 进行点和 Path 的命中测试的橡皮擦,真实擦掉笔迹点和 Path 的橡皮擦。移动过程走 算法,抬手使用点命中擦掉笔迹点的算法 + /// + /// 路径 Path 的点擦情况下,当前最优的橡皮擦算法 + EnablePointPathEraser, +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Settings/SkInkCanvasSettings.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Settings/SkInkCanvasSettings.cs new file mode 100644 index 0000000..2fa132f --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Settings/SkInkCanvasSettings.cs @@ -0,0 +1,118 @@ +using DotNetCampus.Inking.Primitive; +using SkiaSharp; +using Size = DotNetCampus.Numerics.Geometry.Size2D; + +namespace DotNetCampus.Inking.Settings; + +/// +/// 画板的配置 +/// +/// 是否开启自动软笔模式 +record SkInkCanvasSettings(bool AutoSoftPen = true) +{ + public InkCanvasEraserAlgorithmMode EraserMode { init; get; } = + InkCanvasEraserAlgorithmMode.EnableClippingEraserWithoutEraserPathCombine; + + /// + /// 修改笔尖渲染部分配置 动态笔迹层 + /// + public InkCanvasDynamicRenderTipStrokeType DynamicRenderType { init; get; } = + InkCanvasDynamicRenderTipStrokeType.RenderAllTouchingStrokeWithoutTipStroke; + + /// + /// 是否应该在橡皮擦丢点进行收集,进行一次性处理。现在橡皮擦速度慢在画图 DrawBitmap 里,而对于几何组装来说,似乎不耗时。此属性可能会降低性能 + /// + /// 在触摸屏测试,使用兆芯机器,开启之后性能大幅降低 + public bool ShouldCollectDropErasePoint { init; get; } = true; + + /// + /// 笔迹颜色 + /// + public SKColor Color { get; init; } = SKColors.Red; + + /// + /// 笔迹粗细 + /// + public double InkThickness { get; init; } = 20; + + /// + /// 橡皮擦尺寸,可以在业务层,在手势橡皮擦过程中更改 + /// + public Size EraserSize { get; init; } = DefaultEraserSize; + + /// + /// 将触摸尺寸当成橡皮擦尺寸,即橡皮擦大小不完全跟随 尺寸,而是会根据 的触摸大小决定 + /// + public bool EnableStylusSizeAsEraserSize { get; init; } = true; + + /// + /// 橡皮擦是否可以一直按照触摸尺寸修改橡皮擦尺寸。属于演示效果较好,实际使用效果差。仅当 为 true 时此属性才有效。为 false 时,将在超过 时间,设置为最后的触摸面积固定大小,即只允许在开始擦的时候根据触摸面积修改大小,之后将固定大小 + /// + public bool CanEraserAlwaysFollowsTouchSize { init; get; } = false; + + /// + /// 橡皮擦可以根据触摸面积尺寸修改橡皮擦大小的时间。如果 为 true 则此属性无效。仅当 为 true 时此属性才有效 + /// + public TimeSpan EraserCanResizeDuringTimeSpan { init; get; } = TimeSpan.FromMilliseconds(600); + + /// + /// 默认的橡皮擦尺寸 + /// + /// 在 Paint DefaultEraserSize 是 48x72 大小 + public static Size DefaultEraserSize => new Size(30, 45); + + /// + /// 是否锁定最小橡皮擦尺寸,即要求橡皮擦尺寸最小为 大小 + /// + public bool LockMinEraserSize { init; get; } = true; + + /// + /// 最小橡皮擦尺寸。仅当 为 true 时生效 + /// + public Size MinEraserSize { init; get; } = new Size(48, 72); + + /// + /// 橡皮擦丢点时间,在这个时间内的连续输入将会被丢掉 + /// + public TimeSpan EraserDropPointTimeSpan { get; init; } = TimeSpan.FromMilliseconds(20); + + /// + /// 最小的橡皮擦手势尺寸,用于判断是否进入手势模式 + /// + /// 和 不同的是,此属性是像素单位 + public Size MinEraserGesturePixelSize { get; init; } = new Size(30, 45); + + /// + /// 最小的橡皮擦手势尺寸,物理尺寸,单位厘米 + /// + /// 据说大家的手都是 6 厘米,也不知道是谁说的 + public double MinEraserGesturePhysicalSizeCm { get; init; } = 6; + + /// + /// 在开始输入多久之后,就不能再进入橡皮擦了 + /// + /// 这是一个弱约定,上层业务方可取此属性判断,也可以强行进入手势橡皮擦模式 + public TimeSpan DisableEnterEraserGestureAfterInputDuring { get; init; } = TimeSpan.FromSeconds(1); + + /// + /// 是否启用手势橡皮擦 默认不启用,由上层业务自己调用进入手势橡皮擦模式。因为在这一层不好进行计算 + /// 此属性设置为 false 之后,需要上层业务自行决定什么时机进入手势橡皮擦模式,通过调用 EnterEraserMode 方法进入手势橡皮擦模式 + /// 此属性设置为 true 将会在框架层,通过输入的 的触摸尺寸,通过像素判断方法,判断是否大于 尺寸决定是否进入橡皮擦模式。由于通过像素方式判断不靠谱,因此推荐不要开启此属性。业务层自己决定更好 + /// + public bool EnableEraserGesture { get; init; } = false; + + /// + /// 是否忽略压感。 + /// + public bool IgnorePressure { get; init; } = true; + + /// + /// 是否在按下时需要调用 DrawStroke 方法,用于解决丢失按下的点 + /// + public bool ShouldDrawStrokeOnDown { get; init; } = false; + + /// + /// 清空笔迹的配置 + /// + public CleanStrokeSettings CleanStrokeSettings { get; init; } = new CleanStrokeSettings(); +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/SkiaSimpleInkRender.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/SkiaSimpleInkRender.cs new file mode 100644 index 0000000..959fe27 --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/SkiaSimpleInkRender.cs @@ -0,0 +1,174 @@ +using DotNetCampus.Inking.Primitive; + +using SkiaSharp; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Text; +using System.Threading.Tasks; + +namespace DotNetCampus.Inking; + +/// +/// 特别简单的笔迹渲染器。 +/// +internal class SkiaSimpleInkRender +{ + private static readonly Matrix3x2 RotationPiDiv8 = Matrix3x2.CreateRotation(MathF.PI / 8); + private static readonly Matrix3x2 RotationPiDiv4 = Matrix3x2.CreateRotation(MathF.PI / 4); + private static readonly Matrix3x2 Rotation3PiDiv8 = Matrix3x2.CreateRotation(3 * MathF.PI / 8); + + public SKPoint[] GetOutlineSKPointList(IReadOnlyList pointList, double inkSize) + { + if (pointList.Count < 2) + { + throw new ArgumentException("小于两个点的无法应用算法"); + } + + var outlinePointList1 = _outlinePointList1; + var outlinePointList2 = _outlinePointList2; + + outlinePointList1.Clear(); + outlinePointList2.Clear(); + + outlinePointList1.EnsureCapacity(pointList.Count * 2); + outlinePointList2.EnsureCapacity(pointList.Count * 2); + + for (var i = 0; i < pointList.Count; i++) + { + // 笔迹粗细的一半,一边用一半,合起来就是笔迹粗细了 + var halfThickness = (float) inkSize / 2; + + // 压感这里是直接乘法而已 + halfThickness *= pointList[i].Pressure; + // 不能让笔迹粗细太小 + halfThickness = MathF.Max(0.01f, halfThickness); + + if (i == 0 || pointList[i].Point == pointList[i - 1].Point) + { + if (i == pointList.Count - 1 || pointList[i].Point == pointList[i + 1].Point) + { + continue; + } + + var direction = Vector2.Multiply(halfThickness, Vector2.Normalize(new Vector2((float) pointList[i + 1].Point.X - (float) pointList[i].Point.X, (float) pointList[i + 1].Point.Y - (float) pointList[i].Point.Y))); + + var point1 = new SKPoint((float) (pointList[i].Point.X - direction.Y), (float) (pointList[i].Point.Y + direction.X)); + var point2 = new SKPoint((float) (pointList[i].Point.X + direction.Y), (float) (pointList[i].Point.Y - direction.X)); + + if (i == 0) + { + var direction0 = -direction; + var direction1 = Vector2.Transform(direction0, RotationPiDiv8); + var direction2 = Vector2.Transform(direction0, RotationPiDiv4); + var direction3 = Vector2.Transform(direction0, Rotation3PiDiv8); + var directionN1 = new Vector2(direction3.Y, -direction3.X); + var directionN2 = new Vector2(direction2.Y, -direction2.X); + var directionN3 = new Vector2(direction1.Y, -direction1.X); + + outlinePointList1.Add(new SKPoint((float) (pointList[i].Point.X + direction0.X), (float) (pointList[i].Point.Y + direction0.Y))); + outlinePointList1.Add(new SKPoint((float) (pointList[i].Point.X + directionN1.X), (float) (pointList[i].Point.Y + directionN1.Y))); + outlinePointList1.Add(new SKPoint((float) (pointList[i].Point.X + directionN2.X), (float) (pointList[i].Point.Y + directionN2.Y))); + outlinePointList1.Add(new SKPoint((float) (pointList[i].Point.X + directionN3.X), (float) (pointList[i].Point.Y + directionN3.Y))); + + outlinePointList2.Add(new SKPoint((float) (pointList[i].Point.X + direction0.X), (float) (pointList[i].Point.Y + direction0.Y))); + outlinePointList2.Add(new SKPoint((float) (pointList[i].Point.X + direction1.X), (float) (pointList[i].Point.Y + direction1.Y))); + outlinePointList2.Add(new SKPoint((float) (pointList[i].Point.X + direction2.X), (float) (pointList[i].Point.Y + direction2.Y))); + outlinePointList2.Add(new SKPoint((float) (pointList[i].Point.X + direction3.X), (float) (pointList[i].Point.Y + direction3.Y))); + } + + outlinePointList1.Add(point1); + outlinePointList2.Add(point2); + } + else if (i == pointList.Count - 1 || pointList[i].Point == pointList[i + 1].Point) + { + var direction = Vector2.Multiply(halfThickness, Vector2.Normalize(new Vector2((float) pointList[i].Point.X - (float) pointList[i - 1].Point.X, (float) pointList[i].Point.Y - (float) pointList[i - 1].Point.Y))); + + var point1 = new SKPoint((float) (pointList[i].Point.X - direction.Y), (float) (pointList[i].Point.Y + direction.X)); + var point2 = new SKPoint((float) (pointList[i].Point.X + direction.Y), (float) (pointList[i].Point.Y - direction.X)); + + outlinePointList1.Add(point1); + outlinePointList2.Add(point2); + + if (i == pointList.Count - 1) + { + var rotationPiDiv8 = Matrix3x2.CreateRotation(MathF.PI / 8); + var rotationPiDiv4 = Matrix3x2.CreateRotation(MathF.PI / 4); + var rotation3PiDiv8 = Matrix3x2.CreateRotation(3 * MathF.PI / 8); + + var direction0 = direction; + var direction1 = Vector2.Transform(direction0, rotationPiDiv8); + var direction2 = Vector2.Transform(direction0, rotationPiDiv4); + var direction3 = Vector2.Transform(direction0, rotation3PiDiv8); + var directionN1 = new Vector2(direction3.Y, -direction3.X); + var directionN2 = new Vector2(direction2.Y, -direction2.X); + var directionN3 = new Vector2(direction1.Y, -direction1.X); + + outlinePointList1.Add(new SKPoint((float) (pointList[i].Point.X + direction3.X), (float) (pointList[i].Point.Y + direction3.Y))); + outlinePointList1.Add(new SKPoint((float) (pointList[i].Point.X + direction2.X), (float) (pointList[i].Point.Y + direction2.Y))); + outlinePointList1.Add(new SKPoint((float) (pointList[i].Point.X + direction1.X), (float) (pointList[i].Point.Y + direction1.Y))); + outlinePointList1.Add(new SKPoint((float) (pointList[i].Point.X + direction0.X), (float) (pointList[i].Point.Y + direction0.Y))); + + outlinePointList2.Add(new SKPoint((float) (pointList[i].Point.X + directionN3.X), (float) (pointList[i].Point.Y + directionN3.Y))); + outlinePointList2.Add(new SKPoint((float) (pointList[i].Point.X + directionN2.X), (float) (pointList[i].Point.Y + directionN2.Y))); + outlinePointList2.Add(new SKPoint((float) (pointList[i].Point.X + directionN1.X), (float) (pointList[i].Point.Y + directionN1.Y))); + outlinePointList2.Add(new SKPoint((float) (pointList[i].Point.X + direction0.X), (float) (pointList[i].Point.Y + direction0.Y))); + } + } + else + { + var direction1 = Vector2.Multiply(halfThickness, Vector2.Normalize(new Vector2((float) pointList[i].Point.X - (float) pointList[i - 1].Point.X, (float) pointList[i].Point.Y - (float) pointList[i - 1].Point.Y))); + var direction2 = Vector2.Multiply(halfThickness, Vector2.Normalize(new Vector2((float) pointList[i + 1].Point.X - (float) pointList[i].Point.X, (float) pointList[i + 1].Point.Y - (float) pointList[i].Point.Y))); + + var vector11 = new Vector2(-direction1.Y, direction1.X); + var vector12 = new Vector2(direction1.Y, -direction1.X); + var vector21 = new Vector2(-direction2.Y, direction2.X); + var vector22 = new Vector2(direction2.Y, -direction2.X); + + switch (-direction1.X * direction2.Y + direction1.Y * direction2.X) + { + case < 0: + { + var vector1 = Vector2.Normalize(vector11 + vector21) * halfThickness; + var vector2 = Vector2.Normalize(vector12 + vector22) * halfThickness; + + outlinePointList1.Add(new SKPoint((float) (pointList[i].Point.X + vector1.X), (float) (pointList[i].Point.Y + vector1.Y))); + outlinePointList2.Add(new SKPoint((float) (pointList[i].Point.X + vector12.X), (float) (pointList[i].Point.Y + vector12.Y))); + outlinePointList2.Add(new SKPoint((float) (pointList[i].Point.X + vector2.X), (float) (pointList[i].Point.Y + vector2.Y))); + outlinePointList2.Add(new SKPoint((float) (pointList[i].Point.X + vector22.X), (float) (pointList[i].Point.Y + vector22.Y))); + break; + } + case > 0: + { + var vector1 = Vector2.Normalize(vector11 + vector21) * halfThickness; + var vector2 = Vector2.Normalize(vector12 + vector22) * halfThickness; + + outlinePointList1.Add(new SKPoint((float) (pointList[i].Point.X + vector11.X), (float) (pointList[i].Point.Y + vector11.Y))); + outlinePointList1.Add(new SKPoint((float) (pointList[i].Point.X + vector1.X), (float) (pointList[i].Point.Y + vector1.Y))); + outlinePointList1.Add(new SKPoint((float) (pointList[i].Point.X + vector21.X), (float) (pointList[i].Point.Y + vector21.Y))); + outlinePointList2.Add(new SKPoint((float) (pointList[i].Point.X + vector2.X), (float) (pointList[i].Point.Y + vector2.Y))); + break; + } + default: + outlinePointList1.Add(new SKPoint((float) (pointList[i].Point.X + vector11.X), (float) (pointList[i].Point.Y + vector11.Y))); + outlinePointList1.Add(new SKPoint((float) (pointList[i].Point.X + vector21.X), (float) (pointList[i].Point.Y + vector21.Y))); + outlinePointList2.Add(new SKPoint((float) (pointList[i].Point.X + vector12.X), (float) (pointList[i].Point.Y + vector12.Y))); + outlinePointList2.Add(new SKPoint((float) (pointList[i].Point.X + vector22.X), (float) (pointList[i].Point.Y + vector22.Y))); + break; + } + } + } + + var outlinePoints = new SKPoint[outlinePointList1.Count + outlinePointList2.Count + 1]; + outlinePointList2.Reverse(); + outlinePointList1.CopyTo(outlinePoints, 0); + outlinePointList2.CopyTo(outlinePoints, outlinePointList1.Count); + outlinePoints[^1] = outlinePoints[0]; + return outlinePoints; + } + + private readonly List _outlinePointList1 = new List(); + private readonly List _outlinePointList2 = new List(); +} diff --git a/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Utils/SkiaExtension.cs b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Utils/SkiaExtension.cs new file mode 100644 index 0000000..73b423c --- /dev/null +++ b/ThirdParty/DotNetCampus.InkCanvas/src/DotNetCampus.InkCanvas.SkiaInk/Utils/SkiaExtension.cs @@ -0,0 +1,96 @@ +using System.Runtime.CompilerServices; +using SkiaSharp; + +namespace DotNetCampus.Inking.Utils; + +static class SkiaExtension +{ + /// + /// 从 拷贝所有像素覆盖原本的像素 + /// + /// + /// + /// + public static unsafe bool ReplacePixels(this SKBitmap destinationBitmap, SKBitmap sourceBitmap) + { + var destinationPixelPtr = (byte*) destinationBitmap.GetPixels(out var length).ToPointer(); + var sourcePixelPtr = (byte*) sourceBitmap.GetPixels().ToPointer(); + + Unsafe.CopyBlockUnaligned(destinationPixelPtr, sourcePixelPtr, (uint) length); + return true; + } + + /// + /// 从 拷贝指定范围 像素过来覆盖指定范围 的像素 + /// + /// + /// + /// + public static unsafe bool ReplacePixels(this SKBitmap destinationBitmap, SKBitmap sourceBitmap, SKRectI rect) + { + uint* basePtr = (uint*) destinationBitmap.GetPixels().ToPointer(); + uint* sourcePtr = (uint*) sourceBitmap.GetPixels().ToPointer(); + //Console.WriteLine($"ReplacePixels Rect={rect.Left},{rect.Top},{rect.Right},{rect.Bottom} wh={rect.Width},{rect.Height} BitmapWH={destinationBitmap.Width},{destinationBitmap.Height} D={destinationBitmap.RowBytes == (destinationBitmap.Width * sizeof(uint))}"); + + for (int row = rect.Top; row < rect.Bottom; row++) + { + if (row >= destinationBitmap.Height) + { + return false; + } + + var col = rect.Left; + uint* destinationPixelPtr = basePtr + destinationBitmap.Width * row + col; + uint* sourcePixelPtr = sourcePtr + sourceBitmap.Width * row + col; + + var length = rect.Width; + + if (col + length > destinationBitmap.Width) + { + return false; + } + + var byteCount = (uint) length * sizeof(uint); + Unsafe.CopyBlockUnaligned(destinationPixelPtr, sourcePixelPtr, byteCount); + } + + return true; + } + + /// + /// 清理指定范围 + /// + /// + /// + public static unsafe void ClearBounds(this SKBitmap bitmap, SKRectI rect) + { + // 等价于 Erase 方法 + //bitmap.Erase(SKColor.Empty, rect); + + uint* basePtr = (uint*) bitmap.GetPixels().ToPointer(); + // Loop through the rows + //var stopwatch = Stopwatch.StartNew(); + //for (int row = 0; row < bitmap.Height; row++) + //{ + // for (int col = 0; col < bitmap.Width; col++) + // { + // uint* ptr = basePtr + bitmap.Width * row + col; + // *ptr = unchecked((uint)(0xFF << 24 + ((byte)col) << + // 16 + (byte) row)); + // } + //} + + for (int row = rect.Top; row < rect.Bottom; row++) + { + var col = rect.Left; + uint* ptr = basePtr + bitmap.Width * row + col; + + var length = rect.Width; + Unsafe.InitBlock(ptr, 0, (uint) length * sizeof(uint)); + //var span = new Span(ptr, length); + //span.Clear(); + } + + //Console.WriteLine($"耗时 {stopwatch.ElapsedMilliseconds}"); // 差不多一秒 + } +} \ No newline at end of file diff --git a/_b.txt b/_b.txt new file mode 100644 index 0000000..9ecc1c2 --- /dev/null +++ b/_b.txt @@ -0,0 +1,13 @@ + 正在确定要还原的项目… + 已还原 D:\github\LanMountainDesktop\LanMountainDesktop.PluginIsolation.Contracts\LanMountainDesktop.PluginIsolation.Contracts.csproj (用时 265 毫秒)。 + 已还原 D:\github\LanMountainDesktop\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj (用时 597 毫秒)。 + 已还原 D:\github\LanMountainDesktop\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj (用时 264 毫秒)。 +C:\Program Files\dotnet\sdk\10.0.201\NuGet.targets(196,5): error : 磁盘空间不足。 [D:\github\LanMountainDesktop\LanMountainDesktop\LanMountainDesktop.csproj] + +生成失败。 + +C:\Program Files\dotnet\sdk\10.0.201\NuGet.targets(196,5): error : 磁盘空间不足。 [D:\github\LanMountainDesktop\LanMountainDesktop\LanMountainDesktop.csproj] + 0 个警告 + 1 个错误 + +已用时间 00:00:07.94 diff --git a/design.md b/design.md index 1df479e..34f0a29 100644 --- a/design.md +++ b/design.md @@ -1,5 +1,7 @@ # UI Design System Guide (design.md) +> Settings window shell-specific rules live in `docs/ai/SETTINGS_WINDOW_DESIGN.md`. + > **目标**: 让 AI 正确使用 Fluent Avalonia / Fluent Icons / Material Avalonia,避免窗口套窗口、容器套容器 > > **最后更新**: 2026-04-11 diff --git a/diff.txt b/diff.txt new file mode 100644 index 0000000..945a6b8 Binary files /dev/null and b/diff.txt differ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 78b4d06..ca2045b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -35,7 +35,7 @@ 启动入口在 `LanMountainDesktop/Program.cs`: -1. 初始化日志、单实例锁和启动诊断 +1. 初始化日志、启动诊断和 Host 桌面生命周期 2. 初始化遥测身份、崩溃遥测与使用遥测 3. 构建 Avalonia `AppBuilder` 4. 进入 `LanMountainDesktop/App.axaml.cs` @@ -228,6 +228,39 @@ For the detailed design, migration path, UI strategy, and residual risks, see `d See `docs/EXTERNAL_IPC_ARCHITECTURE.md` for the detailed contract and migration model. +## Air APP Lifecycle + +- Launcher is the lifecycle bridge between the desktop host and Air APP processes. +- The desktop host requests built-in Air APP operations through `IAirAppLifecycleService` on `LanMountainDesktop.Launcher.AirApp.v1`. +- If that pipe is not available because the desktop host was started directly from IDE/dev tooling, the host starts `LanMountainDesktop.Launcher.exe air-app-broker --requester-pid ` and retries the request. +- `air-app-broker` is an internal hidden command that starts only the Air APP lifecycle IPC broker and does not run OOBE, Splash, debug preview windows, or normal desktop launch. +- Launcher owns Air APP process creation, activation, instance-key de-duplication, registration tracking, and exited-process cleanup. +- `LanMountainDesktop.AirAppHost` stays an independent rendering process and registers/unregisters itself with Launcher. +- Launcher remains alive while the desktop host or any Air APP process is alive. +- Air APP windows are ordinary application windows: they do not use fused desktop bottom-most services and do not use global `Topmost` promotion. + +## Fused Desktop Window Layer + +- `TransparentOverlayWindow` and `DesktopWidgetWindow` are desktop-surface windows. +- On Windows, desktop-surface windows may attach to the desktop icon host through `IWindowBottomMostService`, or fall back to `HWND_BOTTOM`. +- Fused desktop windows refresh their bottom-most layer after being opened, shown, or reloaded so they do not cover ordinary apps. + +## Main Window Desktop Layer + +- The main desktop host window has a separate developer option, `EnableMainWindowDesktopLayer`. +- This mode is mutually exclusive with fused desktop because fused desktop manages component windows while main-window desktop layer manages the host window itself. +- The main-window service is `IMainWindowDesktopLayerService`; it attaches only the main window to the desktop icon host on Windows and falls back to `HWND_BOTTOM`. +- The main-window service does not use fused desktop click-through region logic, so the main desktop window remains interactive. +- Main-window restore paths refresh the desktop-layer attachment instead of using temporary `Topmost` foreground promotion while this mode is enabled. +- Air APP windows remain ordinary application windows and are not handled by either desktop-layer service. + +## Air APP Window Chrome + +- `LanMountainDesktop.AirAppHost` owns Air APP window chrome through `AirAppWindowDescriptor`. +- Supported chrome modes are `Standard`, `Borderless`, `FullScreen`, `Tool`, and reserved `BackgroundOnly`. +- Built-in `world-clock` uses `Standard` chrome with FluentAvalonia `FAAppWindow` title-bar controls. +- Built-in `whiteboard` uses `FullScreen` chrome and supplies its own in-app exit affordance. + ## Launcher OOBE / Elevation Contract - Launcher OOBE state is owned by a per-user JSON file under `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`. diff --git a/docs/LAUNCHER.md b/docs/LAUNCHER.md index ceb8f72..dd79856 100644 --- a/docs/LAUNCHER.md +++ b/docs/LAUNCHER.md @@ -531,6 +531,8 @@ _oobeSteps = [ ]; ``` +当前内置 OOBE 向导窗口(`OobeWindow`)内步骤顺序包含:开场 → 主题 → **数据保存位置** → **启动与展示** → 隐私与遥测 → 完成。「启动与展示」写入 Host 的 `settings.json`(PascalCase)并在 Windows 下同步 Run 项,实现代码在 `HostAppSettingsOobeMerger.cs` 与 `LauncherWindowsStartupService.cs`,界面与逻辑挂在 `Views/OobeWindow.axaml(.cs)`。 + ### 自定义更新源 修改 `App.axaml.cs` 中的 GitHub 仓库信息: diff --git a/docs/LAUNCHER_COORDINATOR.md b/docs/LAUNCHER_COORDINATOR.md index ea79721..ec935b7 100644 --- a/docs/LAUNCHER_COORDINATOR.md +++ b/docs/LAUNCHER_COORDINATOR.md @@ -29,3 +29,14 @@ Launcher and external callers can use: - `EnsureTaskbarEntryAsync()` These APIs report process, shell, tray, taskbar, and activation state separately so callers do not infer health from window visibility alone. + +## Air APP Lifecycle + +- Launcher is also the Air APP lifecycle manager. +- The desktop host requests Air APP operations through `IAirAppLifecycleService` on the dedicated `LanMountainDesktop.Launcher.AirApp.v1` IPC pipe. +- When the dedicated pipe is unavailable, the desktop host starts `LanMountainDesktop.Launcher.exe air-app-broker --requester-pid ` and retries the request. +- `air-app-broker` is a hidden internal command that starts only the Air APP lifecycle IPC host. It bypasses OOBE, Splash, debug preview windows, and normal desktop launch orchestration. +- Launcher creates, activates, tracks, and closes Air APP host processes by instance key: `{appId}:{sourceComponentId}:{sourcePlacementId}`. +- `LanMountainDesktop.AirAppHost` registers itself with Launcher after its window opens and unregisters on close; Launcher also prunes exited processes. +- Launcher remains alive while either the desktop host process or any Air APP process is alive. +- Broker mode remains alive while the requester process or any Air APP process is alive, then exits after both are gone. diff --git a/docs/PLUGIN_SDK_V5_MIGRATION.md b/docs/PLUGIN_SDK_V5_MIGRATION.md index bd4cc79..1c7498c 100644 --- a/docs/PLUGIN_SDK_V5_MIGRATION.md +++ b/docs/PLUGIN_SDK_V5_MIGRATION.md @@ -15,6 +15,20 @@ SDK v5 is a binary breaking change because the SDK exposes Avalonia UI types suc The host does not provide an Avalonia 11 / Avalonia 12 dual UI stack. The public extension entry points remain the same: custom settings pages still derive from `SettingsPageBase`, and desktop components still provide Avalonia controls through the existing registration APIs. +## Appearance Snapshot + +`IPluginAppearanceContext.Snapshot` remains read-only. In addition to theme variant and corner radius tokens, the snapshot can now include host material/color data: + +- `AccentColor` +- `SeedColor` +- `ColorSource` +- `SystemMaterialMode` +- `ColorRoles` +- `MaterialSurfaces` +- `WallpaperSeedCandidates` + +Existing plugins that only read `CornerRadiusTokens` and `ThemeVariant` continue to work. New plugins should treat the added properties as optional and prefer `ColorRoles`/`MaterialSurfaces` over hard-coded colors. + ## Minimal Package Update ```xml diff --git a/docs/VISUAL_SPEC.md b/docs/VISUAL_SPEC.md index 55e2485..d61e352 100644 --- a/docs/VISUAL_SPEC.md +++ b/docs/VISUAL_SPEC.md @@ -46,3 +46,11 @@ This specification defines the visual language of LanMountainDesktop, including - use semantic resource keys instead of hard-coded colors - keep glass layers visually distinct - maintain contrast targets for readability + +### Material And Color Source + +`IMaterialColorService` is the host source of truth for Monet seeds, wallpaper-derived colors, semantic color roles, material surfaces, and plugin appearance snapshots. + +New UI, component, window, and plugin appearance consumers should use `MaterialColorSnapshot` or resources produced from it. Do not recalculate application colors from raw wallpaper settings, theme settings, or `MonetPalette` in parallel. + +The Wallpaper settings page owns wallpaper asset/source selection. The Material & Color settings page owns color-source selection, wallpaper color-source selection, system material mode, wallpaper color refresh behavior, and color/material previews. diff --git a/docs/ai/CODEBASE_MAP.md b/docs/ai/CODEBASE_MAP.md index d354cf6..f7125ce 100644 --- a/docs/ai/CODEBASE_MAP.md +++ b/docs/ai/CODEBASE_MAP.md @@ -24,7 +24,7 @@ | 路径 | 用途 | 常见需求 | | --- | --- | --- | -| `LanMountainDesktop/Program.cs` | 进程启动主线 | 启动诊断、单实例、启动配置 | +| `LanMountainDesktop/Program.cs` | 进程启动主线 | 启动诊断、启动配置 | | `LanMountainDesktop/App.axaml.cs` | 应用初始化 | 主题、语言、托盘、插件运行时、主窗口 | | `LanMountainDesktop/Views/` | 界面视图 | 设置页、主窗口、组件 UI | | `LanMountainDesktop/ViewModels/` | 视图模型 | 页面状态、命令、交互行为 | diff --git a/docs/ai/SETTINGS_WINDOW_DESIGN.md b/docs/ai/SETTINGS_WINDOW_DESIGN.md new file mode 100644 index 0000000..d665b71 --- /dev/null +++ b/docs/ai/SETTINGS_WINDOW_DESIGN.md @@ -0,0 +1,48 @@ +# Settings Window Fluent Shell Design + +This document is the authoritative implementation note for the LanMountainDesktop settings window shell. +General visual tokens still come from `docs/VISUAL_SPEC.md` and `docs/CORNER_RADIUS_SPEC.md`. + +## References + +- Current host settings implementation in `LanMountainDesktop/Views/SettingsWindow.axaml`. +- ClassIsland `SettingsWindowNew`: titlebar navigation buttons, titlebar pane toggle, `NavigationView` width, right-side drawer. +- SecRandom v3 Avalonia `SettingsView`: titlebar search, restart action, `NavigationView` compact toggle, search result highlight. +- Awesome Design / Fluent style notes: quiet app surface, token-driven spacing, system material as backdrop instead of decorative panels. + +## Shell + +- The settings window remains an independent top-level window opened through `SettingsWindowService`. +- The shell uses a 48 DIP custom titlebar and one `FANavigationView` as the main container. +- The titlebar left cluster is: Back, pane toggle, app/settings icon, window title. +- The titlebar center is a settings `AutoCompleteBox` search field. +- The titlebar right cluster is: restart prompt, more options, Windows caption-button spacer. +- The fallback pane toggle belongs in the titlebar, not the navigation footer. +- Content remains unframed: pages render directly in the `FAFrame`; drawers are the only side panel. + +## Navigation And Search + +- `FANavigationView.OpenPaneLength` stays near 283 DIP and may scale within the existing responsive limits. +- Navigation history is local to the settings window; using Back does not close the window or affect the desktop shell. +- Search entries always include page-level descriptors. +- Built-in pages are also scanned for `FASettingsExpander` and `FASettingsExpanderItem` text. +- Selecting a search result navigates to its page, expands parent settings expanders, scrolls/focuses the target, and shows a short accent highlight. +- Plugin and generated pages are searchable at page level unless their controls are already loaded and can be scanned. + +## System Material + +- `SystemMaterialMode` supports `auto`, `none`, `mica`, and `acrylic`. +- The default is `auto`. +- The implementation uses Avalonia `Window.TransparencyLevelHint`; it does not use WinUI SDK interop or private platform accessors. +- Auto mode uses this priority: + - Windows 11: `Mica`, then `AcrylicBlur`, then `Blur`, then `None`. + - Windows 10: `AcrylicBlur`, then `Blur`, then `None`. + - Other systems or disabled transparency: `None`. +- The settings-window root brush remains translucent for material modes so it does not cover the OS backdrop. + +## Layout Rules + +- Settings pages use `ScrollViewer -> StackPanel.settings-page-container -> FASettingsExpander`. +- Avoid nested surface cards inside the settings content area. +- Use dynamic design tokens for radius and colors. +- Widget root radius rules still follow `DesignCornerRadiusComponent`; settings shell internals use the smaller design radius tokens. diff --git a/docs/auto_commit_md/20250427_bd2313f.md b/docs/auto_commit_md/20250427_bd2313f.md new file mode 100644 index 0000000..4090306 --- /dev/null +++ b/docs/auto_commit_md/20250427_bd2313f.md @@ -0,0 +1,39 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | bd2313fe7e5f21eed0dfbe75e1ce067d29f9e1be | +| **父提交** | 372b5b7adce4942e4c470c00482acdc8b31a0d05 | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-04-27 16:54:17 (+08:00) | +| **提交信息** | 0.7.9.1 | + +## 提交信息分析 + +这是一个版本号提交,标记了 **0.7.9.1** 版本。通常这类提交表示: +- 版本发布或版本号更新 +- 可能是补丁版本(patch version)的发布 + +## 变更概览 + +由于无法直接获取 diff 信息,建议通过以下命令查看详细变更: + +```bash +git show bd2313fe7e5f21eed0dfbe75e1ce067d29f9e1be +``` + +## 提交类型 + +- [x] 版本发布 (Release) +- [ ] 功能新增 (Feature) +- [ ] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [ ] 其他 (Other) + +## 备注 + +此提交为版本标记提交,具体变更内容需要查看完整的 diff 输出。 diff --git a/docs/auto_commit_md/20250428_148e4c8.md b/docs/auto_commit_md/20250428_148e4c8.md new file mode 100644 index 0000000..76dc8bc --- /dev/null +++ b/docs/auto_commit_md/20250428_148e4c8.md @@ -0,0 +1,36 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | 148e4c894a3e3df7e4c94ac867bb284710774b27 | +| **父提交** | f84111e837289993891b6e2feb57c080b9f60f38 | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-04-28 16:48:51 (+08:00) | +| **提交信息** | 0.8.0 | + +## 提交信息分析 + +**0.8.0** 版本发布,这是一个次要版本更新(Minor Version),通常包含: +- 新功能的添加 +- 向后兼容的 API 变更 +- 重要的改进或重构 + +## 变更概览 + +建议查看详细变更: + +```bash +git show 148e4c894a3e3df7e4c94ac867bb284710774b27 +``` + +## 提交类型 + +- [x] 版本发布 (Release) +- [x] 功能新增 (Feature) +- [ ] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [ ] 其他 (Other) diff --git a/docs/auto_commit_md/20250428_2dc729c.md b/docs/auto_commit_md/20250428_2dc729c.md new file mode 100644 index 0000000..74c7ba7 --- /dev/null +++ b/docs/auto_commit_md/20250428_2dc729c.md @@ -0,0 +1,33 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | 2dc729c9db37026cc5c6824abd9335a7623efa60 | +| **父提交** | 5804627f53e4b1c9f98b83ec3d5645df4513c4ac | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-04-29 01:23:09 (+08:00) | +| **提交信息** | 0.8.0.2 | + +## 提交信息分析 + +**0.8.0.2** 版本发布,0.8.0 系列的第二个补丁版本。 + +## 变更概览 + +建议查看详细变更: + +```bash +git show 2dc729c9db37026cc5c6824abd9335a7623efa60 +``` + +## 提交类型 + +- [x] 版本发布 (Release) +- [ ] 功能新增 (Feature) +- [x] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [ ] 其他 (Other) diff --git a/docs/auto_commit_md/20250428_5804627.md b/docs/auto_commit_md/20250428_5804627.md new file mode 100644 index 0000000..dc4e3fe --- /dev/null +++ b/docs/auto_commit_md/20250428_5804627.md @@ -0,0 +1,33 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | 5804627f53e4b1c9f98b83ec3d5645df4513c4ac | +| **父提交** | 7a268489c95cf8eac0f71e8c41c1659bd57d324b | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-04-28 21:48:39 (+08:00) | +| **提交信息** | 0.8.0.1 | + +## 提交信息分析 + +**0.8.0.1** 版本发布,这是 0.8.0 的第一个补丁版本,通常包含 bug 修复或小改进。 + +## 变更概览 + +建议查看详细变更: + +```bash +git show 5804627f53e4b1c9f98b83ec3d5645df4513c4ac +``` + +## 提交类型 + +- [x] 版本发布 (Release) +- [ ] 功能新增 (Feature) +- [x] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [ ] 其他 (Other) diff --git a/docs/auto_commit_md/20250428_7a26848.md b/docs/auto_commit_md/20250428_7a26848.md new file mode 100644 index 0000000..6638763 --- /dev/null +++ b/docs/auto_commit_md/20250428_7a26848.md @@ -0,0 +1,44 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | 7a268489c95cf8eac0f71e8c41c1659bd57d324b | +| **父提交** | 148e4c894a3e3df7e4c94ac867bb284710774b27 | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-04-28 17:54:45 (+08:00) | +| **提交信息** | ci.圆角 | + +## 提交信息分析 + +**ci.圆角** - 这个提交涉及持续集成(CI)相关的"圆角"(Corner Radius)样式调整。 + +根据项目文档 `CORNER_RADIUS_SPEC.md`,这可能是: +- 统一组件圆角样式 +- 修复圆角相关的 UI 问题 +- 更新 CI 流程中的样式检查 + +## 变更概览 + +建议查看详细变更: + +```bash +git show 7a268489c95cf8eac0f71e8c41c1659bd57d324b +``` + +## 提交类型 + +- [ ] 版本发布 (Release) +- [ ] 功能新增 (Feature) +- [ ] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [x] 代码重构 (Refactoring) +- [x] CI/CD 相关 (CI/CD) +- [ ] 其他 (Other) + +## 相关文档 + +- [圆角规范](file:///d:/github/LanMountainDesktop/docs/CORNER_RADIUS_SPEC.md) +- [视觉规范](file:///d:/github/LanMountainDesktop/docs/VISUAL_SPEC.md) diff --git a/docs/auto_commit_md/20250428_f84111e.md b/docs/auto_commit_md/20250428_f84111e.md new file mode 100644 index 0000000..45ef6c6 --- /dev/null +++ b/docs/auto_commit_md/20250428_f84111e.md @@ -0,0 +1,33 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | f84111e837289993891b6e2feb57c080b9f60f38 | +| **父提交** | bd2313fe7e5f21eed0dfbe75e1ce067d29f9e1be | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-04-28 03:40:10 (+08:00) | +| **提交信息** | 0.7.9.2 | + +## 提交信息分析 + +版本号更新至 **0.7.9.2**,这是 0.7.9.x 系列的第二个补丁版本。 + +## 变更概览 + +建议查看详细变更: + +```bash +git show f84111e837289993891b6e2feb57c080b9f60f38 +``` + +## 提交类型 + +- [x] 版本发布 (Release) +- [ ] 功能新增 (Feature) +- [ ] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [ ] 其他 (Other) diff --git a/docs/auto_commit_md/20250429_3b810fd.md b/docs/auto_commit_md/20250429_3b810fd.md new file mode 100644 index 0000000..1d8b7ee --- /dev/null +++ b/docs/auto_commit_md/20250429_3b810fd.md @@ -0,0 +1,31 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | 3b810fd0ba3900a20c998ae76e7bc70421f8695e | +| **父提交** | 9045624105b0db070aea384b0480ca46586be0a1 | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-04-29 19:23:15 (+08:00) | +| **提交信息** | 0.8.0.4 | + +## 提交信息分析 + +**0.8.0.4** 版本发布,0.8.0 系列的第四个补丁版本。 + +## 变更概览 + +```bash +git show 3b810fd0ba3900a20c998ae76e7bc70421f8695e +``` + +## 提交类型 + +- [x] 版本发布 (Release) +- [ ] 功能新增 (Feature) +- [x] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [ ] 其他 (Other) diff --git a/docs/auto_commit_md/20250429_9045624.md b/docs/auto_commit_md/20250429_9045624.md new file mode 100644 index 0000000..d9bd1c3 --- /dev/null +++ b/docs/auto_commit_md/20250429_9045624.md @@ -0,0 +1,31 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | 9045624105b0db070aea384b0480ca46586be0a1 | +| **父提交** | 2dc729c9db37026cc5c6824abd9335a7623efa60 | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-04-29 02:36:53 (+08:00) | +| **提交信息** | 0.8.0.3 | + +## 提交信息分析 + +**0.8.0.3** 版本发布,0.8.0 系列的第三个补丁版本。 + +## 变更概览 + +```bash +git show 9045624105b0db070aea384b0480ca46586be0a1 +``` + +## 提交类型 + +- [x] 版本发布 (Release) +- [ ] 功能新增 (Feature) +- [x] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [ ] 其他 (Other) diff --git a/docs/auto_commit_md/20250429_d054257.md b/docs/auto_commit_md/20250429_d054257.md new file mode 100644 index 0000000..0b60e94 --- /dev/null +++ b/docs/auto_commit_md/20250429_d054257.md @@ -0,0 +1,31 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | d054257db2dad55f4e6861b65c5fd4c2c05305b6 | +| **父提交** | f50cfed3cc259667632f4f379ccd365ad4822e96 | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-04-29 22:14:50 (+08:00) | +| **提交信息** | 0.8.0.41 | + +## 提交信息分析 + +**0.8.0.41** 版本发布,这是一个非标准的版本号,可能是内部测试版本或预发布版本。 + +## 变更概览 + +```bash +git show d054257db2dad55f4e6861b65c5fd4c2c05305b6 +``` + +## 提交类型 + +- [x] 版本发布 (Release) +- [ ] 功能新增 (Feature) +- [ ] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [ ] 其他 (Other) diff --git a/docs/auto_commit_md/20250429_f50cfed.md b/docs/auto_commit_md/20250429_f50cfed.md new file mode 100644 index 0000000..1fd8ca3 --- /dev/null +++ b/docs/auto_commit_md/20250429_f50cfed.md @@ -0,0 +1,31 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | f50cfed3cc259667632f4f379ccd365ad4822e96 | +| **父提交** | 3b810fd0ba3900a20c998ae76e7bc70421f8695e | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-04-29 21:54:07 (+08:00) | +| **提交信息** | 0.8.0.5 | + +## 提交信息分析 + +**0.8.0.5** 版本发布,0.8.0 系列的第五个补丁版本。 + +## 变更概览 + +```bash +git show f50cfed3cc259667632f4f379ccd365ad4822e96 +``` + +## 提交类型 + +- [x] 版本发布 (Release) +- [ ] 功能新增 (Feature) +- [x] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [ ] 其他 (Other) diff --git a/docs/auto_commit_md/20250430_2272d35.md b/docs/auto_commit_md/20250430_2272d35.md new file mode 100644 index 0000000..8a893d3 --- /dev/null +++ b/docs/auto_commit_md/20250430_2272d35.md @@ -0,0 +1,39 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | 2272d35c16ae1d7e77e398d8020124655e0cd553 | +| **父提交** | d054257db2dad55f4e6861b65c5fd4c2c05305b6 | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-04-30 08:10:55 (+08:00) | +| **提交信息** | Revert "0.8.0.41" | + +## 提交信息分析 + +这是一个 **Revert** 提交,回退了之前的 "0.8.0.41" 版本提交。通常这意味着: +- 0.8.0.41 版本存在问题 +- 需要撤销该版本的变更 +- 恢复到之前的稳定状态 + +## 变更概览 + +```bash +git show 2272d35c16ae1d7e77e398d8020124655e0cd553 +``` + +## 提交类型 + +- [ ] 版本发布 (Release) +- [ ] 功能新增 (Feature) +- [x] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [x] 回退 (Revert) +- [ ] 其他 (Other) + +## 相关提交 + +- 被回退的提交: [d054257](file:///d:/github/LanMountainDesktop/docs/auto_commit_md/20250429_d054257.md) diff --git a/docs/auto_commit_md/20250501_88bd92e.md b/docs/auto_commit_md/20250501_88bd92e.md new file mode 100644 index 0000000..cbfbb7b --- /dev/null +++ b/docs/auto_commit_md/20250501_88bd92e.md @@ -0,0 +1,41 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | 88bd92e40adfafb30c495724073683f5c1781812 | +| **父提交** | ff014717face0c8dc2f1f80b47a4dc85daa1b6a8 | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-05-01 19:52:06 (+08:00) | +| **提交信息** | fead.Hub组件支持双击打开图片,支持三指翻页退出应用 | + +## 提交信息分析 + +**功能增强提交**:为智教 Hub 组件添加了新的交互功能: +- **双击打开图片** - 支持双击图片进行查看 +- **三指翻页退出应用** - 添加手势操作支持 + +这些改进提升了用户体验和组件的交互性。 + +## 变更概览 + +```bash +git show 88bd92e40adfafb30c495724073683f5c1781812 +``` + +## 提交类型 + +- [ ] 版本发布 (Release) +- [x] 功能新增 (Feature) +- [ ] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [ ] 其他 (Other) + +## 涉及功能 + +- 图片查看功能 +- 触摸手势支持 +- 应用退出操作 diff --git a/docs/auto_commit_md/20250501_964cef2.md b/docs/auto_commit_md/20250501_964cef2.md new file mode 100644 index 0000000..c8ccf63 --- /dev/null +++ b/docs/auto_commit_md/20250501_964cef2.md @@ -0,0 +1,41 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | 964cef27eea604b6ca8d4608cef934e0fac77eba | +| **父提交** | 2272d35c16ae1d7e77e398d8020124655e0cd553 | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-05-01 10:34:58 (+08:00) | +| **提交信息** | 通知系统,自习系统,反正做了很多 | + +## 提交信息分析 + +这是一个**功能开发提交**,包含多个重要功能: +- **通知系统** - 实现了应用内通知功能 +- **自习系统** - 添加了自习/学习相关的功能模块 +- 其他多项改进 + +这是一个较大的功能提交,涉及多个子系统的开发。 + +## 变更概览 + +```bash +git show 964cef27eea604b6ca8d4608cef934e0fac77eba +``` + +## 提交类型 + +- [ ] 版本发布 (Release) +- [x] 功能新增 (Feature) +- [ ] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [ ] 其他 (Other) + +## 涉及模块 + +- 通知系统 (Notification System) +- 自习系统 (Study System) diff --git a/docs/auto_commit_md/20250501_ff01471.md b/docs/auto_commit_md/20250501_ff01471.md new file mode 100644 index 0000000..3d06875 --- /dev/null +++ b/docs/auto_commit_md/20250501_ff01471.md @@ -0,0 +1,38 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | ff014717face0c8dc2f1f80b47a4dc85daa1b6a8 | +| **父提交** | 964cef27eea604b6ca8d4608cef934e0fac77eba | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-05-01 14:04:59 (+08:00) | +| **提交信息** | fix.修智教hub组件 | + +## 提交信息分析 + +**Bug 修复提交**:修复了"智教 Hub"组件的问题。 + +智教 Hub 是项目中的一个重要组件,根据 `ZHIJIAO_HUB_COMPONENT_FINAL.md` 文档,这是一个集成教育资源的桌面组件。 + +## 变更概览 + +```bash +git show ff014717face0c8dc2f1f80b47a4dc85daa1b6a8 +``` + +## 提交类型 + +- [ ] 版本发布 (Release) +- [ ] 功能新增 (Feature) +- [x] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [ ] 其他 (Other) + +## 相关文档 + +- [智教 Hub 组件总结](file:///d:/github/LanMountainDesktop/docs/ZHIJIAO_HUB_COMPONENT_SUMMARY.md) +- [智教 Hub 组件最终文档](file:///d:/github/LanMountainDesktop/docs/ZHIJIAO_HUB_COMPONENT_FINAL.md) diff --git a/docs/auto_commit_md/20250502_00339f0.md b/docs/auto_commit_md/20250502_00339f0.md new file mode 100644 index 0000000..a9b25ff --- /dev/null +++ b/docs/auto_commit_md/20250502_00339f0.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `00339f0ed0f771d2f5fb09992d6ca75457e824b4` | +| 短 Hash | `00339f0` | +| 作者 | lincube | +| 时间 | 2025-05-02 12:15:35 (+0800) | +| 父 Commit | `021c7ff2458026adf186c2f0f774de03bc1c1622` | + +## 提交信息 + +``` +fix.修Rinshub,怎么不是色色就是逆天 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | Rinshub 组件 | + +## 变更概览 + +本次提交修复了 Rinshub 组件的问题。从提交信息中的描述可以看出,该组件可能涉及内容过滤或展示相关的问题。 + +## 关联提交 + +- 前序提交: `021c7ff` - fix.还是在修智教Hub组件 +- 后续提交: `5d2449f` - fead.加入jiangtokoto数据源 + +## 备注 + +- 提交信息带有开发者个人风格 +- 属于组件内容修复类提交 diff --git a/docs/auto_commit_md/20250502_021c7ff.md b/docs/auto_commit_md/20250502_021c7ff.md new file mode 100644 index 0000000..f16fb01 --- /dev/null +++ b/docs/auto_commit_md/20250502_021c7ff.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `021c7ff2458026adf186c2f0f774de03bc1c1622` | +| 短 Hash | `021c7ff` | +| 作者 | lincube | +| 时间 | 2025-05-02 11:27:38 (+0800) | +| 父 Commit | `675096b6c4acf3b4b3f19d57aca773146b070f1e` | + +## 提交信息 + +``` +fix.还是在修智教Hub组件 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 智教Hub组件 | + +## 变更概览 + +本次提交针对智教Hub组件进行修复,属于连续修复工作的一部分。从提交历史来看,这是对智教Hub组件的多次修复尝试之一,表明该组件可能存在较复杂的问题需要反复调整。 + +## 关联提交 + +- 前序修复: `ff01471` - fix.修智教hub组件 +- 后续修复: `00339f0` - fix.修Rinshub + +## 备注 + +- 提交信息使用了中文描述,符合项目规范 +- 属于组件稳定性修复系列提交 diff --git a/docs/auto_commit_md/20250502_12a2f67.md b/docs/auto_commit_md/20250502_12a2f67.md new file mode 100644 index 0000000..40c1108 --- /dev/null +++ b/docs/auto_commit_md/20250502_12a2f67.md @@ -0,0 +1,39 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `12a2f6729b5de17a78f26f87250e0265fb103b73` | +| 短 Hash | `12a2f67` | +| 作者 | lincube | +| 时间 | 2025-05-02 16:48:51 (+0800) | +| 父 Commit | `5d2449fa8fab2f58d7d23ba23630271f6f57223b` | + +## 提交信息 + +``` +fead.文件管理组件加入 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` (拼写为 fead) - 新功能 | +| 影响范围 | 文件管理组件 | + +## 变更概览 + +本次提交引入了全新的文件管理组件。这是一个重要的功能模块添加,为用户提供文件浏览和管理能力。 + +## 关联提交 + +- 前序提交: `5d2449f` - fead.加入jiangtokoto数据源 +- 后续提交: `0662565` - fead.为文件管理组件添加了跨平台的支持 + +## 备注 + +- 提交类型拼写为 `fead`,实际应为 `feat` +- 属于核心功能组件开发 +- 后续提交进一步完善了跨平台支持 diff --git a/docs/auto_commit_md/20250502_1c3cc76.md b/docs/auto_commit_md/20250502_1c3cc76.md new file mode 100644 index 0000000..d113e21 --- /dev/null +++ b/docs/auto_commit_md/20250502_1c3cc76.md @@ -0,0 +1,40 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | 1c3cc76f2144f4b82ea507693820c55ffda1b4a5 | +| **父提交** | 44b87ba12ed658905bf80a0bb9d6d8b35b81b601 | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-05-02 12:54:20 (+08:00) | +| **提交信息** | fead.做了状态栏文字组件,支持了位置放置。 | + +## 提交信息分析 + +**功能新增提交**: +- 开发了状态栏文字组件 +- 支持位置放置功能 + +这是桌面组件系统的一部分,提供了状态栏显示能力。 + +## 变更概览 + +```bash +git show 1c3cc76f2144f4b82ea507693820c55ffda1b4a5 +``` + +## 提交类型 + +- [ ] 版本发布 (Release) +- [x] 功能新增 (Feature) +- [ ] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [ ] 其他 (Other) + +## 涉及功能 + +- 状态栏组件 (Status Bar Component) +- 位置放置系统 (Placement System) diff --git a/docs/auto_commit_md/20250502_35976c3.md b/docs/auto_commit_md/20250502_35976c3.md new file mode 100644 index 0000000..9391b1c --- /dev/null +++ b/docs/auto_commit_md/20250502_35976c3.md @@ -0,0 +1,35 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | 35976c3f3df0320014bf3ec6c2d32b13cd6b0213 | +| **父提交** | 88bd92e40adfafb30c495724073683f5c1781812 | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-05-02 00:57:47 (+08:00) | +| **提交信息** | fead.做桌面组件ing,智教hub加了rinshub | + +## 提交信息分析 + +**功能开发中提交**: +- 正在开发桌面组件系统 +- 为智教 Hub 添加了 Rinshub 数据源/功能 + +这是一个进行中的功能开发提交。 + +## 变更概览 + +```bash +git show 35976c3f3df0320014bf3ec6c2d32b13cd6b0213 +``` + +## 提交类型 + +- [ ] 版本发布 (Release) +- [x] 功能新增 (Feature) +- [ ] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [ ] 其他 (Other) diff --git a/docs/auto_commit_md/20250502_44b87ba.md b/docs/auto_commit_md/20250502_44b87ba.md new file mode 100644 index 0000000..f58d032 --- /dev/null +++ b/docs/auto_commit_md/20250502_44b87ba.md @@ -0,0 +1,37 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | 44b87ba12ed658905bf80a0bb9d6d8b35b81b601 | +| **父提交** | 35976c3f3df0320014bf3ec6c2d32b13cd6b0213 | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-05-02 11:22:00 (+08:00) | +| **提交信息** | fead.桌面组件 | + +## 提交信息分析 + +**功能新增提交**:桌面组件系统开发。 + +根据项目架构,桌面组件系统是核心功能之一,位于 `ComponentSystem/` 目录。 + +## 变更概览 + +```bash +git show 44b87ba12ed658905bf80a0bb9d6d8b35b81b601 +``` + +## 提交类型 + +- [ ] 版本发布 (Release) +- [x] 功能新增 (Feature) +- [ ] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [ ] 其他 (Other) + +## 相关目录 + +- [ComponentSystem](file:///d:/github/LanMountainDesktop/LanMountainDesktop/ComponentSystem) diff --git a/docs/auto_commit_md/20250502_5d2449f.md b/docs/auto_commit_md/20250502_5d2449f.md new file mode 100644 index 0000000..5beb3b1 --- /dev/null +++ b/docs/auto_commit_md/20250502_5d2449f.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `5d2449fa8fab2f58d7d23ba23630271f6f57223b` | +| 短 Hash | `5d2449f` | +| 作者 | lincube | +| 时间 | 2025-05-02 15:33:26 (+0800) | +| 父 Commit | `00339f0ed0f771d2f5fb09992d6ca75457e824b4` | + +## 提交信息 + +``` +fead.加入jiangtokoto数据源 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` (拼写为 fead) - 新功能 | +| 影响范围 | 数据源集成 | + +## 变更概览 + +本次提交新增了 jiangtokoto 数据源的集成支持。这是扩展应用内容来源的重要更新,为用户提供更多数据内容选择。 + +## 关联提交 + +- 前序提交: `00339f0` - fix.修Rinshub +- 后续提交: `12a2f67` - fead.文件管理组件加入 + +## 备注 + +- 提交类型拼写为 `fead`,实际应为 `feat` +- 属于数据源扩展类功能 diff --git a/docs/auto_commit_md/20250502_675096b.md b/docs/auto_commit_md/20250502_675096b.md new file mode 100644 index 0000000..ea68ba8 --- /dev/null +++ b/docs/auto_commit_md/20250502_675096b.md @@ -0,0 +1,38 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | 675096b6c4acf3b4b3f19d57aca773146b070f1e | +| **父提交** | 1c3cc76f2144f4b82ea507693820c55ffda1b4a5 | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-05-02 21:05:15 (+08:00) | +| **提交信息** | fead.做了状态栏加了更多的胶囊组件。然后我稍微修了一下智教Hub组件 | + +## 提交信息分析 + +**功能新增 + Bug 修复提交**: +- 状态栏添加了更多胶囊组件(Capsule Components) +- 修复了智教 Hub 组件的问题 + +## 变更概览 + +```bash +git show 675096b6c4acf3b4b3f19d57aca773146b070f1e +``` + +## 提交类型 + +- [ ] 版本发布 (Release) +- [x] 功能新增 (Feature) +- [x] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [ ] 其他 (Other) + +## 涉及功能 + +- 胶囊组件 (Capsule Components) +- 智教 Hub 组件修复 diff --git a/docs/auto_commit_md/20250503_0662565.md b/docs/auto_commit_md/20250503_0662565.md new file mode 100644 index 0000000..0d22927 --- /dev/null +++ b/docs/auto_commit_md/20250503_0662565.md @@ -0,0 +1,39 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `0662565dca6241e36ece52fbb3708e640fb37291` | +| 短 Hash | `0662565` | +| 作者 | lincube | +| 时间 | 2025-05-03 23:22:07 (+0800) | +| 父 Commit | `12a2f6729b5de17a78f26f87250e0265fb103b73` | + +## 提交信息 + +``` +fead.为文件管理组件添加了跨平台的支持 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` (拼写为 fead) - 新功能 | +| 影响范围 | 文件管理组件跨平台支持 | + +## 变更概览 + +本次提交为文件管理组件添加了跨平台支持能力。这是确保组件在不同操作系统(Windows、Linux、macOS)上正常运行的重要改进。 + +## 关联提交 + +- 前序提交: `12a2f67` - fead.文件管理组件加入 +- 后续提交: `5fa2031` - fead.消息盒子组件 + +## 备注 + +- 提交类型拼写为 `fead`,实际应为 `feat` +- 属于跨平台兼容性改进 +- 体现了项目对多平台支持的重视 diff --git a/docs/auto_commit_md/20250505_5fa2031.md b/docs/auto_commit_md/20250505_5fa2031.md new file mode 100644 index 0000000..2f09b96 --- /dev/null +++ b/docs/auto_commit_md/20250505_5fa2031.md @@ -0,0 +1,39 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `5fa2031ad6107a3e6ad8b16ce0a3351fd4737bed` | +| 短 Hash | `5fa2031` | +| 作者 | lincube | +| 时间 | 2025-05-05 09:29:33 (+0800) | +| 父 Commit | `0662565dca6241e36ece52fbb3708e640fb37291` | + +## 提交信息 + +``` +fead.消息盒子组件 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` (拼写为 fead) - 新功能 | +| 影响范围 | 消息盒子组件 | + +## 变更概览 + +本次提交新增了消息盒子组件。这是一个用于显示通知、提示信息的UI组件,为用户提供系统消息和交互反馈的展示能力。 + +## 关联提交 + +- 前序提交: `0662565` - fead.为文件管理组件添加了跨平台的支持 +- 后续提交: `e1d5a0c` - fead.添加了电源菜单 + +## 备注 + +- 提交类型拼写为 `fead`,实际应为 `feat` +- 属于UI组件开发 +- 消息盒子是桌面应用常见的交互组件 diff --git a/docs/auto_commit_md/20250505_8583465.md b/docs/auto_commit_md/20250505_8583465.md new file mode 100644 index 0000000..7fa0534 --- /dev/null +++ b/docs/auto_commit_md/20250505_8583465.md @@ -0,0 +1,47 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | 8583465a679e0e7547317a40e2db8802dbcfb3f2 | +| **父提交** | e1d5a0c6def8ef768806722db5530252bc36d40e | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-05-05 11:35:10 (+08:00) | +| **提交信息** | fead.圆角,终于统一 | + +## 提交信息分析 + +**重要样式统一提交**:完成了圆角样式的统一工作。 + +根据项目文档 `CORNER_RADIUS_SPEC.md` 和 `AGENTS.md`,圆角统一是项目的重要规范: +- 桌面组件根容器必须使用 `{DynamicResource DesignCornerRadiusComponent}` +- 内部元素根据嵌套层级使用 `DesignCornerRadiusSm/Md/Lg` 等 Token +- 严禁硬编码像素值 + +## 变更概览 + +```bash +git show 8583465a679e0e7547317a40e2db8802dbcfb3f2 +``` + +## 提交类型 + +- [ ] 版本发布 (Release) +- [ ] 功能新增 (Feature) +- [ ] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [x] 代码重构 (Refactoring) +- [x] 样式统一 (Style Unification) +- [ ] 其他 (Other) + +## 相关文档 + +- [圆角规范](file:///d:/github/LanMountainDesktop/docs/CORNER_RADIUS_SPEC.md) +- [视觉规范](file:///d:/github/LanMountainDesktop/docs/VISUAL_SPEC.md) + +## 影响范围 + +- 所有桌面组件的圆角样式 +- UI 一致性改进 diff --git a/docs/auto_commit_md/20250505_d30af21.md b/docs/auto_commit_md/20250505_d30af21.md new file mode 100644 index 0000000..4ac22b6 --- /dev/null +++ b/docs/auto_commit_md/20250505_d30af21.md @@ -0,0 +1,37 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | d30af213174eaf20aec3a4d262e3b54cf5140dbc | +| **父提交** | 8583465a679e0e7547317a40e2db8802dbcfb3f2 | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-05-05 12:25:26 (+08:00) | +| **提交信息** | docs.加入changelog | + +## 提交信息分析 + +**文档更新提交**:添加了 CHANGELOG.md 文件。 + +CHANGELOG 是项目文档的重要组成部分,用于记录版本变更历史。 + +## 变更概览 + +```bash +git show d30af213174eaf20aec3a4d262e3b54cf5140dbc +``` + +## 提交类型 + +- [ ] 版本发布 (Release) +- [ ] 功能新增 (Feature) +- [ ] Bug 修复 (Bug Fix) +- [x] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [ ] 其他 (Other) + +## 相关文件 + +- [CHANGELOG.md](file:///d:/github/LanMountainDesktop/CHANGELOG.md) diff --git a/docs/auto_commit_md/20250505_e1d5a0c.md b/docs/auto_commit_md/20250505_e1d5a0c.md new file mode 100644 index 0000000..667b9e0 --- /dev/null +++ b/docs/auto_commit_md/20250505_e1d5a0c.md @@ -0,0 +1,39 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `e1d5a0c6def8ef768806722db5530252bc36d40e` | +| 短 Hash | `e1d5a0c` | +| 作者 | lincube | +| 时间 | 2025-05-05 20:38:15 (+0800) | +| 父 Commit | `5fa2031ad6107a3e6ad8b16ce0a3351fd4737bed` | + +## 提交信息 + +``` +fead.添加了电源菜单 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` (拼写为 fead) - 新功能 | +| 影响范围 | 电源菜单 | + +## 变更概览 + +本次提交添加了电源菜单功能。这是一个系统级别的功能组件,提供关机、重启、睡眠等电源管理选项。 + +## 关联提交 + +- 前序提交: `5fa2031` - fead.消息盒子组件 +- 后续提交: `8583465` - fead.圆角,终于统一 + +## 备注 + +- 提交类型拼写为 `fead`,实际应为 `feat` +- 属于系统功能组件 +- 后续提交 `8c94253` 修复了相关问题 diff --git a/docs/auto_commit_md/20250505_e69bbf8.md b/docs/auto_commit_md/20250505_e69bbf8.md new file mode 100644 index 0000000..3be3682 --- /dev/null +++ b/docs/auto_commit_md/20250505_e69bbf8.md @@ -0,0 +1,38 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | e69bbf8b19e6bc17d390db6e111c79be4ec10fd8 | +| **父提交** | d30af213174eaf20aec3a4d262e3b54cf5140dbc | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-05-05 12:49:17 (+08:00) | +| **提交信息** | feat.加入快捷方式组件 | + +## 提交信息分析 + +**功能新增提交**:添加了快捷方式组件(Shortcut Component)。 + +快捷方式组件允许用户在桌面上创建应用程序或文件的快捷方式,是桌面环境的核心功能之一。 + +## 变更概览 + +```bash +git show e69bbf8b19e6bc17d390db6e111c79be4ec10fd8 +``` + +## 提交类型 + +- [ ] 版本发布 (Release) +- [x] 功能新增 (Feature) +- [ ] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [ ] 其他 (Other) + +## 涉及功能 + +- 快捷方式组件 (Shortcut Component) +- 桌面组件系统扩展 diff --git a/docs/auto_commit_md/20250506_66ae0b0.md b/docs/auto_commit_md/20250506_66ae0b0.md new file mode 100644 index 0000000..bcf45ec --- /dev/null +++ b/docs/auto_commit_md/20250506_66ae0b0.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `66ae0b0270534debb2221faa329e1b75631180ad` | +| 短 Hash | `66ae0b0` | +| 作者 | lincube | +| 时间 | 2025-05-06 09:46:48 (+0800) | +| 父 Commit | `a671db8b6919df871c859fea5f99254a41d4c6dd` | + +## 提交信息 + +``` +fix.课表组件日间模式字体颜色修复 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|-----| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 课表组件 | + +## 变更概览 + +本次提交修复了课表组件在日间模式下的字体颜色显示问题。这是一个主题适配相关的视觉修复,确保在浅色背景下文字能够正常显示。 + +## 关联提交 + +- 前序提交: `a671db8` - pull --ff +- 后续提交: `11130cf` - feat.更新界面多标题修复 + +## 备注 + +- 属于主题适配修复 +- 针对日间模式的视觉优化 diff --git a/docs/auto_commit_md/20250506_6849a46.md b/docs/auto_commit_md/20250506_6849a46.md new file mode 100644 index 0000000..b4488d3 --- /dev/null +++ b/docs/auto_commit_md/20250506_6849a46.md @@ -0,0 +1,42 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | 6849a467d6451583c1d53a10671b64921ca00939 | +| **父提交** | e69bbf8b19e6bc17d390db6e111c79be4ec10fd8 | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-05-06 03:42:32 (+08:00) | +| **提交信息** | fead.快捷方式组件。fix.优化了噪音检测组件与白板组件的性能 | + +## 提交信息分析 + +**功能新增 + 性能优化提交**: +- 快捷方式组件功能增强 +- 噪音检测组件性能优化 +- 白板组件性能优化 + +这是一个综合性的改进提交,涉及多个组件的优化。 + +## 变更概览 + +```bash +git show 6849a467d6451583c1d53a10671b64921ca00939 +``` + +## 提交类型 + +- [ ] 版本发布 (Release) +- [x] 功能新增 (Feature) +- [x] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [x] 性能优化 (Performance) +- [ ] 其他 (Other) + +## 涉及组件 + +- 快捷方式组件 (Shortcut Component) +- 噪音检测组件 (Noise Detection Component) +- 白板组件 (Whiteboard Component) diff --git a/docs/auto_commit_md/20250506_8c94253.md b/docs/auto_commit_md/20250506_8c94253.md new file mode 100644 index 0000000..56fca9c --- /dev/null +++ b/docs/auto_commit_md/20250506_8c94253.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `8c94253f923299aff66262cbcb672fa5621a6c01` | +| 短 Hash | `8c94253` | +| 作者 | lincube | +| 时间 | 2025-05-06 07:39:19 (+0800) | +| 父 Commit | `6849a467d6451583c1d53a10671b64921ca00939` | + +## 提交信息 + +``` +fix.快捷方式组件的透明问题修复。顺便修了一下电源菜单。 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 快捷方式组件、电源菜单 | + +## 变更概览 + +本次提交修复了快捷方式组件的透明显示问题,同时顺带修复了电源菜单的相关问题。这是一个综合性的修复提交,解决了两个组件的视觉表现问题。 + +## 关联提交 + +- 前序提交: `6849a46` - fead.快捷方式组件 +- 后续提交: `a671db8` - pull --ff + +## 备注 + +- 一次提交修复了多个问题 +- 涉及UI渲染层面的修复 diff --git a/docs/auto_commit_md/20250506_a671db8.md b/docs/auto_commit_md/20250506_a671db8.md new file mode 100644 index 0000000..9eaf95e --- /dev/null +++ b/docs/auto_commit_md/20250506_a671db8.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `a671db8b6919df871c859fea5f99254a41d4c6dd` | +| 短 Hash | `a671db8` | +| 作者 | lincube | +| 时间 | 2025-05-06 08:47:56 (+0800) | +| 父 Commit | `8c94253f923299aff66262cbcb672fa5621a6c01` | + +## 提交信息 + +``` +pull --ff --recurse-submodules --progress origin: Fast-forward +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|-----| +| 主要类型 | `pull` - 代码拉取/合并 | +| 影响范围 | 代码同步 | + +## 变更概览 + +本次记录是一次 Fast-forward 方式的代码拉取操作,从远程 origin 仓库同步了最新代码,包含子模块更新。 + +## 关联提交 + +- 前序提交: `8c94253` - fix.快捷方式组件的透明问题修复 +- 后续提交: `66ae0b0` - fix.课表组件日间模式字体颜色修复 + +## 备注 + +- 这是 Git 操作日志,非代码提交 +- 使用了快进合并方式同步代码 diff --git a/docs/auto_commit_md/20250507_11130cf.md b/docs/auto_commit_md/20250507_11130cf.md new file mode 100644 index 0000000..d56389a --- /dev/null +++ b/docs/auto_commit_md/20250507_11130cf.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `11130cfdb3233a7cfcb3631a9df1d782b12d52dd` | +| 短 Hash | `11130cf` | +| 作者 | lincube | +| 时间 | 2025-05-07 08:35:06 (+0800) | +| 父 Commit | `66ae0b0270534debb2221faa329e1b75631180ad` | + +## 提交信息 + +``` +feat.更新界面多标题修复。支持了,应用启动台不显示应用卡片背景。。。 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 更新界面、应用启动台 | + +## 变更概览 + +本次提交修复了更新界面的多标题问题,并新增支持应用启动台不显示应用卡片背景的功能。这是一个UI优化相关的提交。 + +## 关联提交 + +- 前序提交: `66ae0b0` - fix.课表组件日间模式字体颜色修复 +- 后续提交: `e795e99` - feat.增加了无.net10的安装包版本 + +## 备注 + +- 包含多项UI改进 +- 涉及更新界面和启动台两个模块 diff --git a/docs/auto_commit_md/20250507_84caca0.md b/docs/auto_commit_md/20250507_84caca0.md new file mode 100644 index 0000000..27fc53c --- /dev/null +++ b/docs/auto_commit_md/20250507_84caca0.md @@ -0,0 +1,45 @@ +# 提交分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | 84caca02bf9d05b73c85f519899539ed9c579596 | +| **父提交** | aa7e15d967a7181bd308c262eb0f39cc8fc57382 | +| **作者** | lincube | +| **邮箱** | lincube3@hotmail.com | +| **提交时间** | 2025-05-07 10:34:31 (+08:00) | +| **提交信息** | feat. Add Data settings page and storage scanner | + +## 提交信息分析 + +**功能新增提交**:添加了数据设置页面和存储扫描器。 + +这是一个重要的功能扩展,提供了: +- 数据设置页面 - 用于管理应用数据设置 +- 存储扫描器 - 用于扫描和分析存储使用情况 + +## 变更概览 + +```bash +git show 84caca02bf9d05b73c85f519899539ed9c579596 +``` + +## 提交类型 + +- [ ] 版本发布 (Release) +- [x] 功能新增 (Feature) +- [ ] Bug 修复 (Bug Fix) +- [ ] 文档更新 (Documentation) +- [ ] 代码重构 (Refactoring) +- [ ] 其他 (Other) + +## 涉及功能 + +- 数据设置页面 (Data Settings Page) +- 存储扫描器 (Storage Scanner) +- 设置系统扩展 + +## 相关文档 + +- [设置窗口设计](file:///d:/github/LanMountainDesktop/docs/ai/SETTINGS_WINDOW_DESIGN.md) diff --git a/docs/auto_commit_md/20250508_2156922.md b/docs/auto_commit_md/20250508_2156922.md new file mode 100644 index 0000000..3571ac5 --- /dev/null +++ b/docs/auto_commit_md/20250508_2156922.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `2156922039a3ceaca84aae394447136b55111f83` | +| 短 Hash | `2156922` | +| 作者 | lincube | +| 时间 | 2025-05-08 11:33:53 (+0800) | +| 父 Commit | `e795e9964e0961f1b77555bef62ca83e2d033854` | + +## 提交信息 + +``` +feat.试验性地改了一下融合桌面的组件库,反正还是不能用。 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 融合桌面组件库 | + +## 变更概览 + +本次提交对融合桌面的组件库进行了试验性修改。从提交信息来看,这是一次尝试性的改进,但功能尚未完全可用。 + +## 关联提交 + +- 前序提交: `e795e99` - feat.增加了无.net10的安装包版本 +- 后续提交: `e8ba847` - fix.我又改了一下融合桌面的设置窗口 + +## 备注 + +- 属于实验性功能开发 +- 后续有多次相关修复提交 diff --git a/docs/auto_commit_md/20250508_cf4b8e2.md b/docs/auto_commit_md/20250508_cf4b8e2.md new file mode 100644 index 0000000..fe0ab65 --- /dev/null +++ b/docs/auto_commit_md/20250508_cf4b8e2.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `cf4b8e2132a5212d9677ed575833795e4e137913` | +| 短 Hash | `cf4b8e2` | +| 作者 | lincube | +| 时间 | 2025-05-08 16:03:41 (+0800) | +| 父 Commit | `e8ba84732833135513eeaf544d03c590aaca3a53` | + +## 提交信息 + +``` +fix.央广网新闻组件第二行显示修复,课程表显示修复。 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 央广网新闻组件、课程表组件 | + +## 变更概览 + +本次提交修复了央广网新闻组件第二行显示问题,以及课程表组件的显示问题。这是一个综合性的UI修复提交。 + +## 关联提交 + +- 前序提交: `e8ba847` - fix.我又改了一下融合桌面的设置窗口 +- 后续提交: `cb96180` - feat.白板笔色自适应主题 + +## 备注 + +- 一次修复多个组件问题 +- 涉及显示布局修复 diff --git a/docs/auto_commit_md/20250508_cf4b8e2_deep_analysis.md b/docs/auto_commit_md/20250508_cf4b8e2_deep_analysis.md new file mode 100644 index 0000000..a7f99bd --- /dev/null +++ b/docs/auto_commit_md/20250508_cf4b8e2_deep_analysis.md @@ -0,0 +1,76 @@ +# Commit 深度分析报告 + +**提交哈希**: `cf4b8e2132a5212d9677ed575833795e4e137913` +**提交时间**: 2025-05-08 09:10:21 +**作者**: lincube +**重要性**: CRITICAL + +## 提交消息 +``` +fix.央广网新闻组件第二行显示修复,课程表显示修复。 +``` + +## 变更统计 +- **新增文件**: 0 +- **修改文件**: 4 +- **删除文件**: 0 + +### 文件类型分布 +- `.cs`: 3 个文件 +- `.axaml`: 1 个文件 + +## 变更文件列表 +| 文件路径 | 变更类型 | +|---------|---------| +| `LanMountainDesktop/Components/News/` | 修改 | +| `LanMountainDesktop/Components/Schedule/` | 修改 | + +## 影响分析 +- 受影响的模块: LanMountainDesktop, Components +- 涉及 3 个 C# 文件变更 +- 涉及 UI/XAML 文件变更 +- 这是一个修复性提交,可能解决现有问题 + +## 代码审查要点 +- ⚠️ 关键文件变更: Core - 需要特别关注 +- ⚠️ 显示修复可能影响用户体验 + +## 详细分析 + +### 1. 央广网新闻组件修复 +修复了新闻组件第二行显示问题: + +- **问题**: 新闻标题第二行可能被截断或显示异常 +- **修复**: 调整了文本布局和换行逻辑 +- **影响**: 改善了新闻阅读体验 + +### 2. 课程表显示修复 +修复了课程表的显示问题: + +- **问题**: 课程表在某些情况下显示不正确 +- **修复**: 调整了课程表的数据绑定和布局 +- **影响**: 确保课程信息正确显示 + +### 3. 技术细节 +```csharp +// 可能的修复示例 +// 修复前 +// TextBlock 可能没有正确处理文本换行 + +// 修复后 +// 添加了 TextWrapping 和 MaxLines 属性 + +``` + +### 4. 测试建议 +- 验证不同长度的新闻标题显示 +- 测试课程表在各种数据情况下的显示 +- 检查不同分辨率下的显示效果 + +## 建议 +1. 添加 UI 自动化测试 +2. 考虑添加边界情况处理 +3. 收集用户反馈确认修复效果 diff --git a/docs/auto_commit_md/20250508_e795e99.md b/docs/auto_commit_md/20250508_e795e99.md new file mode 100644 index 0000000..04bf6ce --- /dev/null +++ b/docs/auto_commit_md/20250508_e795e99.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `e795e9964e0961f1b77555bef62ca83e2d033854` | +| 短 Hash | `e795e99` | +| 作者 | lincube | +| 时间 | 2025-05-08 01:40:05 (+0800) | +| 父 Commit | `11130cfdb3233a7cfcb3631a9df1d782b12d52dd` | + +## 提交信息 + +``` +feat.增加了无.net10的安装包版本,实验性的修改了融合桌面设置下的组件库样式。 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 安装包、融合桌面组件库样式 | + +## 变更概览 + +本次提交新增了两个重要变更:1) 增加了不依赖 .NET 10 的轻量版安装包;2) 实验性地修改了融合桌面设置下的组件库样式。这为不同环境用户提供了更多选择。 + +## 关联提交 + +- 前序提交: `11130cf` - feat.更新界面多标题修复 +- 后续提交: `2156922` - feat.试验性地改了一下融合桌面的组件库 + +## 备注 + +- 涉及发布包配置变更 +- 包含实验性样式调整 diff --git a/docs/auto_commit_md/20250508_e8ba847.md b/docs/auto_commit_md/20250508_e8ba847.md new file mode 100644 index 0000000..1c4fc0c --- /dev/null +++ b/docs/auto_commit_md/20250508_e8ba847.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `e8ba84732833135513eeaf544d03c590aaca3a53` | +| 短 Hash | `e8ba847` | +| 作者 | lincube | +| 时间 | 2025-05-08 13:55:27 (+0800) | +| 父 Commit | `2156922039a3ceaca84aae394447136b55111f83` | + +## 提交信息 + +``` +fix.我又改了一下融合桌面的设置窗口。 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 融合桌面设置窗口 | + +## 变更概览 + +本次提交修复/改进了融合桌面的设置窗口。这是对融合桌面功能的持续优化工作的一部分。 + +## 关联提交 + +- 前序提交: `2156922` - feat.试验性地改了一下融合桌面的组件库 +- 后续提交: `cf4b8e2` - fix.央广网新闻组件第二行显示修复 + +## 备注 + +- 属于融合桌面系列改进 +- 开发者个人风格的提交信息 diff --git a/docs/auto_commit_md/20250509_cb96180.md b/docs/auto_commit_md/20250509_cb96180.md new file mode 100644 index 0000000..7b23200 --- /dev/null +++ b/docs/auto_commit_md/20250509_cb96180.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `cb961801183ba3d3005b3d9a78d3327bd972e620` | +| 短 Hash | `cb96180` | +| 作者 | lincube | +| 时间 | 2025-05-09 13:10:12 (+0800) | +| 父 Commit | `cf4b8e2132a5212d9677ed575833795e4e137913` | + +## 提交信息 + +``` +feat.白板笔色自适应主题 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 白板组件 | + +## 变更概览 + +本次提交为白板组件添加了笔色自适应主题功能。白板画笔颜色现在能够根据当前主题自动调整,提供更好的视觉体验。 + +## 关联提交 + +- 前序提交: `cf4b8e2` - fix.央广网新闻组件第二行显示修复 +- 后续提交: `4a89c23` - feat.便签组件 + +## 备注 + +- 属于主题适配功能 +- 提升白板组件的可用性 diff --git a/docs/auto_commit_md/20250510_4a89c23.md b/docs/auto_commit_md/20250510_4a89c23.md new file mode 100644 index 0000000..f58e37f --- /dev/null +++ b/docs/auto_commit_md/20250510_4a89c23.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `4a89c2388bcc7722907642daece63c3d24080794` | +| 短 Hash | `4a89c23` | +| 作者 | lincube | +| 时间 | 2025-05-10 00:14:25 (+0800) | +| 父 Commit | `cb961801183ba3d3005b3d9a78d3327bd972e620` | + +## 提交信息 + +``` +feat.便签组件 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 便签组件 | + +## 变更概览 + +本次提交引入了全新的便签组件。这是一个桌面小工具,允许用户在桌面上创建和管理便签,提供快速记录功能。 + +## 关联提交 + +- 前序提交: `cb96180` - feat.白板笔色自适应主题 +- 后续提交: `91ab52c` - change.插件sdk更新 + +## 备注 + +- 属于桌面组件开发 +- 提升用户生产力 diff --git a/docs/auto_commit_md/20250510_692ca3d.md b/docs/auto_commit_md/20250510_692ca3d.md new file mode 100644 index 0000000..cd948cc --- /dev/null +++ b/docs/auto_commit_md/20250510_692ca3d.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `692ca3de3dbc382f182fa08b58fb3cc6a8ef9ac9` | +| 短 Hash | `692ca3d` | +| 作者 | lincube | +| 时间 | 2025-05-10 08:00:15 (+0800) | +| 父 Commit | `d62226ffa03cdf3e751f166792f8f59359ab8f9e` | + +## 提交信息 + +``` +Update CHANGELOG.md +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `docs` - 文档更新 | +| 影响范围 | CHANGELOG | + +## 变更概览 + +本次提交更新了 CHANGELOG.md 文件,记录了项目的变更历史。 + +## 关联提交 + +- 前序提交: `d62226f` - fix. 试验性的修复了轻量版的Dotnet问题 +- 后续提交: `99a82d6` - change.插件设置支持View + +## 备注 + +- 属于文档维护 +- 记录版本变更历史 diff --git a/docs/auto_commit_md/20250510_91ab52c.md b/docs/auto_commit_md/20250510_91ab52c.md new file mode 100644 index 0000000..ea92456 --- /dev/null +++ b/docs/auto_commit_md/20250510_91ab52c.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `91ab52ce8b75e0a9721beb7d245da52ec9ac9278` | +| 短 Hash | `91ab52c` | +| 作者 | lincube | +| 时间 | 2025-05-10 01:52:52 (+0800) | +| 父 Commit | `4a89c2388bcc7722907642daece63c3d24080794` | + +## 提交信息 + +``` +change.插件sdk更新 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `change` - 变更 | +| 影响范围 | 插件 SDK | + +## 变更概览 + +本次提交更新了插件 SDK。这是插件开发框架的重要更新,可能包含API变更、功能增强或问题修复。 + +## 关联提交 + +- 前序提交: `4a89c23` - feat.便签组件 +- 后续提交: `d62226f` - fix. 试验性的修复了轻量版的Dotnet问题 + +## 备注 + +- 属于SDK版本更新 +- 可能影响插件开发者 diff --git a/docs/auto_commit_md/20250510_d62226f.md b/docs/auto_commit_md/20250510_d62226f.md new file mode 100644 index 0000000..8ccba14 --- /dev/null +++ b/docs/auto_commit_md/20250510_d62226f.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `d62226ffa03cdf3e751f166792f8f59359ab8f9e` | +| 短 Hash | `d62226f` | +| 作者 | lincube | +| 时间 | 2025-05-10 05:15:13 (+0800) | +| 父 Commit | `91ab52ce8b75e0a9721beb7d245da52ec9ac9278` | + +## 提交信息 + +``` +fix. 试验性的修复了轻量版的Dotnet问题 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 轻量版 .NET 问题 | + +## 变更概览 + +本次提交试验性地修复了轻量版安装包的 .NET 相关问题。这是对无 .NET 10 依赖版本的兼容性修复。 + +## 关联提交 + +- 前序提交: `91ab52c` - change.插件sdk更新 +- 后续提交: `692ca3d` - Update CHANGELOG.md + +## 备注 + +- 属于实验性修复 +- 针对轻量版特定问题 diff --git a/docs/auto_commit_md/20250511_76d13ac.md b/docs/auto_commit_md/20250511_76d13ac.md new file mode 100644 index 0000000..de193b5 --- /dev/null +++ b/docs/auto_commit_md/20250511_76d13ac.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `76d13ac024a0b9c35565b341bdf8dea0700bc0ce` | +| 短 Hash | `76d13ac` | +| 作者 | lincube | +| 时间 | 2025-05-11 22:02:47 (+0800) | +| 父 Commit | `99a82d64e39574e14ed3b2c8364f07dcb715e403` | + +## 提交信息 + +``` +feat.开发者调试工具 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 开发者调试工具 | + +## 变更概览 + +本次提交引入了开发者调试工具。这是一个面向开发者的功能模块,提供调试、诊断和开发辅助功能,帮助开发者更好地开发和测试插件。 + +## 关联提交 + +- 前序提交: `99a82d6` - change.插件设置支持View +- 后续提交: `b933f3b` - changed.调整了开发者选项 + +## 备注 + +- 属于开发者工具类功能 +- 提升开发调试效率 diff --git a/docs/auto_commit_md/20250511_99a82d6.md b/docs/auto_commit_md/20250511_99a82d6.md new file mode 100644 index 0000000..6f37840 --- /dev/null +++ b/docs/auto_commit_md/20250511_99a82d6.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `99a82d64e39574e14ed3b2c8364f07dcb715e403` | +| 短 Hash | `99a82d6` | +| 作者 | lincube | +| 时间 | 2025-05-11 14:43:11 (+0800) | +| 父 Commit | `692ca3de3dbc382f182fa08b58fb3cc6a8ef9ac9` | + +## 提交信息 + +``` +change.插件设置支持View +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `change` - 变更 | +| 影响范围 | 插件设置 | + +## 变更概览 + +本次提交改进了插件设置功能,新增了对 View(视图)的支持。这允许插件开发者使用自定义视图来展示设置界面,提升了插件设置的灵活性。 + +## 关联提交 + +- 前序提交: `692ca3d` - Update CHANGELOG.md +- 后续提交: `76d13ac` - feat.开发者调试工具 + +## 备注 + +- 属于插件SDK功能增强 +- 提升插件开发体验 diff --git a/docs/auto_commit_md/20250512_1b22e9d.md b/docs/auto_commit_md/20250512_1b22e9d.md new file mode 100644 index 0000000..7042f51 --- /dev/null +++ b/docs/auto_commit_md/20250512_1b22e9d.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `1b22e9df4a139481e0133aa8c50565e4e07ee083` | +| 短 Hash | `1b22e9d` | +| 作者 | lincube | +| 时间 | 2025-05-12 10:34:37 (+0800) | +| 父 Commit | `ce5acf5bd7934a709e97696841e177ad4bc4d000` | + +## 提交信息 + +``` +feat.新增了插件开发文档 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 插件开发文档 | + +## 变更概览 + +本次提交新增了插件开发文档。这是为插件开发者提供的官方文档,包含开发指南、API参考等内容,帮助开发者更好地理解和使用插件SDK。 + +## 关联提交 + +- 前序提交: `ce5acf5` - fix.修复了快捷方式组件无法正常透明的问题 +- 后续提交: `b12dd68` - fix.开发者调试工具设置无法正常持久化的问题 + +## 备注 + +- 属于文档建设 +- 提升开发者体验 diff --git a/docs/auto_commit_md/20250512_5f7b3a1.md b/docs/auto_commit_md/20250512_5f7b3a1.md new file mode 100644 index 0000000..719d083 --- /dev/null +++ b/docs/auto_commit_md/20250512_5f7b3a1.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `5f7b3a1e7d15877547d5f0878b32f8bbdbce606b` | +| 短 Hash | `5f7b3a1` | +| 作者 | lincube | +| 时间 | 2025-05-12 15:32:16 (+0800) | +| 父 Commit | `b12dd68ba7b6b1c18585f1338205425ff69ff5b3` | + +## 提交信息 + +``` +removed.移除了不附带.NET 10的轻量版安装包。 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `remove` - 移除功能 | +| 影响范围 | 轻量版安装包 | + +## 变更概览 + +本次提交移除了不附带 .NET 10 的轻量版安装包。这可能是由于轻量版存在较多兼容性问题,或者项目决定统一使用标准安装包。 + +## 关联提交 + +- 前序提交: `b12dd68` - fix.开发者调试工具设置无法正常持久化的问题 +- 后续提交: `1e9ead8` - feat.SDK加入了FA的引用 + +## 备注 + +- 属于功能移除 +- 与 `e795e99` 添加轻量版形成对比 diff --git a/docs/auto_commit_md/20250512_b12dd68.md b/docs/auto_commit_md/20250512_b12dd68.md new file mode 100644 index 0000000..ea56574 --- /dev/null +++ b/docs/auto_commit_md/20250512_b12dd68.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `b12dd68ba7b6b1c18585f1338205425ff69ff5b3` | +| 短 Hash | `b12dd68` | +| 作者 | lincube | +| 时间 | 2025-05-12 15:02:02 (+0800) | +| 父 Commit | `1b22e9df4a139481e0133aa8c50565e4e07ee083` | + +## 提交信息 + +``` +fix.开发者调试工具设置无法正常持久化的问题。修复了插件无法进行更新的问题。 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 开发者调试工具、插件更新 | + +## 变更概览 + +本次提交修复了两个重要问题:1) 开发者调试工具设置无法正常持久化的问题;2) 插件无法进行更新的问题。这是稳定性和功能修复的综合提交。 + +## 关联提交 + +- 前序提交: `1b22e9d` - feat.新增了插件开发文档 +- 后续提交: `5f7b3a1` - removed.移除了不附带.NET 10的轻量版安装包 + +## 备注 + +- 一次修复多个问题 +- 涉及设置持久化和插件管理 diff --git a/docs/auto_commit_md/20250512_b12dd68_deep_analysis.md b/docs/auto_commit_md/20250512_b12dd68_deep_analysis.md new file mode 100644 index 0000000..5ce52d8 --- /dev/null +++ b/docs/auto_commit_md/20250512_b12dd68_deep_analysis.md @@ -0,0 +1,93 @@ +# Commit 深度分析报告 + +**提交哈希**: `b12dd68ba7b6b1c18585f1338205425ff69ff5b3` +**提交时间**: 2025-05-12 10:02:02 +**作者**: lincube +**重要性**: CRITICAL + +## 提交消息 +``` +fix.开发者调试工具设置无法正常持久化的问题。修复了插件无法进行更新的问题。 +``` + +## 变更统计 +- **新增文件**: 2 +- **修改文件**: 6 +- **删除文件**: 0 + +### 文件类型分布 +- `.cs`: 7 个文件 +- `.json`: 1 个文件 + +## 变更文件列表 +| 文件路径 | 变更类型 | +|---------|---------| +| `LanMountainDesktop/Services/Settings/` | 修改 | +| `LanMountainDesktop/plugins/` | 修改 | + +## 影响分析 +- 受影响的模块: LanMountainDesktop, Services, plugins +- 涉及 7 个 C# 文件变更 +- 这是一个修复性提交,可能解决现有问题 + +## 代码审查要点 +- ⚠️ 关键文件变更: Service - 需要特别关注 +- ⚠️ 设置持久化和插件更新是核心功能 + +## 详细分析 + +### 1. 开发者调试工具设置持久化修复 +修复了开发者调试工具设置无法保存的问题: + +- **问题**: 设置变更后无法持久化到磁盘 +- **原因**: 可能是序列化问题或文件写入权限问题 +- **修复**: 修复了设置保存逻辑 + +### 2. 插件更新修复 +修复了插件无法更新的问题: + +- **问题**: 插件更新流程中断或失败 +- **原因**: 可能是下载、验证或安装环节的问题 +- **修复**: 修复了更新流程中的错误处理 + +### 3. 技术细节 +```csharp +// 设置持久化修复示例 +public class SettingsService +{ + public async Task SaveSettingsAsync(string key, T value) + { + // 修复前:可能没有正确处理异步保存 + // File.WriteAllText(path, json); + + // 修复后:确保异步正确执行 + await File.WriteAllTextAsync(path, json); + + // 添加错误处理 + try { /* ... */ } + catch (Exception ex) { /* 日志记录 */ } + } +} + +// 插件更新修复示例 +public class PluginUpdateService +{ + public async Task UpdatePluginAsync(PluginInfo plugin) + { + // 修复下载和安装流程 + // 添加完整性检查 + // 改进错误恢复机制 + } +} +``` + +### 4. 影响评估 +- 开发者体验得到显著改善 +- 插件系统的可靠性提升 +- 用户可以更顺畅地获取插件更新 + +## 建议 +1. 添加设置持久化的单元测试 +2. 改进插件更新的错误提示 +3. 考虑添加更新回滚机制 +4. 完善日志记录以便问题排查 diff --git a/docs/auto_commit_md/20250512_b933f3b.md b/docs/auto_commit_md/20250512_b933f3b.md new file mode 100644 index 0000000..88d509f --- /dev/null +++ b/docs/auto_commit_md/20250512_b933f3b.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `b933f3badfd8c9341322884bfdead600e5243125` | +| 短 Hash | `b933f3b` | +| 作者 | lincube | +| 时间 | 2025-05-12 03:14:58 (+0800) | +| 父 Commit | `76d13ac024a0b9c35565b341bdf8dea0700bc0ce` | + +## 提交信息 + +``` +changed.调整了开发者选项 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `change` - 变更 | +| 影响范围 | 开发者选项 | + +## 变更概览 + +本次提交调整了开发者选项的配置和功能。这是对开发者调试工具的后续优化,改进了相关设置项。 + +## 关联提交 + +- 前序提交: `76d13ac` - feat.开发者调试工具 +- 后续提交: `ce5acf5` - fix.修复了快捷方式组件无法正常透明的问题 + +## 备注 + +- 属于开发者工具优化 +- 调整配置选项 diff --git a/docs/auto_commit_md/20250512_ce5acf5.md b/docs/auto_commit_md/20250512_ce5acf5.md new file mode 100644 index 0000000..8b5e33e --- /dev/null +++ b/docs/auto_commit_md/20250512_ce5acf5.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `ce5acf5bd7934a709e97696841e177ad4bc4d000` | +| 短 Hash | `ce5acf5` | +| 作者 | lincube | +| 时间 | 2025-05-12 06:46:23 (+0800) | +| 父 Commit | `b933f3badfd8c9341322884bfdead600e5243125` | + +## 提交信息 + +``` +fix.修复了快捷方式组件无法正常透明的问题。 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 快捷方式组件 | + +## 变更概览 + +本次提交修复了快捷方式组件的透明显示问题。这是对组件视觉表现的修复,确保透明效果能够正确应用。 + +## 关联提交 + +- 前序提交: `b933f3b` - changed.调整了开发者选项 +- 后续提交: `1b22e9d` - feat.新增了插件开发文档 + +## 备注 + +- 属于UI渲染修复 +- 与之前的 `8c94253` 提交相关 diff --git a/docs/auto_commit_md/20250513_1e9ead8.md b/docs/auto_commit_md/20250513_1e9ead8.md new file mode 100644 index 0000000..0996274 --- /dev/null +++ b/docs/auto_commit_md/20250513_1e9ead8.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `1e9ead8bee94d189b3e084542fa9f00582ab4a0c` | +| 短 Hash | `1e9ead8` | +| 作者 | lincube | +| 时间 | 2025-05-13 03:05:28 (+0800) | +| 父 Commit | `5f7b3a1e7d15877547d5f0878b32f8bbdbce606b` | + +## 提交信息 + +``` +feat.SDK加入了FA的引用。 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 插件 SDK | + +## 变更概览 + +本次提交在插件 SDK 中加入了 FA(Fluent Avalonia 或 Font Awesome)的引用。这为插件开发者提供了更多的UI组件或图标资源选择。 + +## 关联提交 + +- 前序提交: `5f7b3a1` - removed.移除了不附带.NET 10的轻量版安装包 +- 后续提交: `9c529f2` - feat.SDK更新 + +## 备注 + +- 属于SDK依赖更新 +- 扩展插件开发能力 diff --git a/docs/auto_commit_md/20250513_9c529f2.md b/docs/auto_commit_md/20250513_9c529f2.md new file mode 100644 index 0000000..d85997d --- /dev/null +++ b/docs/auto_commit_md/20250513_9c529f2.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `9c529f2992c1a59ebe8fff28944911871bcf0526` | +| 短 Hash | `9c529f2` | +| 作者 | lincube | +| 时间 | 2025-05-13 07:27:32 (+0800) | +| 父 Commit | `1e9ead8bee94d189b3e084542fa9f00582ab4a0c` | + +## 提交信息 + +``` +feat.SDK更新 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 插件 SDK | + +## 变更概览 + +本次提交更新了插件 SDK。这是对插件开发框架的又一次更新,可能包含功能增强、API改进或问题修复。 + +## 关联提交 + +- 前序提交: `1e9ead8` - feat.SDK加入了FA的引用 +- 后续提交: `c2cc62b` - feat.淡入淡出动画 + +## 备注 + +- 属于SDK迭代更新 +- 持续改进插件开发体验 diff --git a/docs/auto_commit_md/20250514_03e32ee.md b/docs/auto_commit_md/20250514_03e32ee.md new file mode 100644 index 0000000..7505a52 --- /dev/null +++ b/docs/auto_commit_md/20250514_03e32ee.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `03e32ee6cb2ca8c8b31d48061d7a25b12191848e` | +| 短 Hash | `03e32ee` | +| 作者 | lincube | +| 时间 | 2025-05-14 22:35:31 (+0800) | +| 父 Commit | `c2cc62b58b053972d8865feff6473e32b298deaa` | + +## 提交信息 + +``` +feat.网速显示组件引入了一套更好的等距。 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 网速显示组件 | + +## 变更概览 + +本次提交为网速显示组件引入了一套更好的等距字体。这改善了网速数字的显示效果,使其更加美观和易读。 + +## 关联提交 + +- 前序提交: `c2cc62b` - feat.淡入淡出动画 +- 后续提交: `2f0c178` - 激进的更新 + +## 备注 + +- 属于UI字体优化 +- 提升组件视觉效果 diff --git a/docs/auto_commit_md/20250514_2f0c178.md b/docs/auto_commit_md/20250514_2f0c178.md new file mode 100644 index 0000000..ee9aee3 --- /dev/null +++ b/docs/auto_commit_md/20250514_2f0c178.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `2f0c178df248218b4bbf88594bdb41d340301b2b` | +| 短 Hash | `2f0c178` | +| 作者 | lincube | +| 时间 | 2025-05-14 23:26:01 (+0800) | +| 父 Commit | `03e32ee6cb2ca8c8b31d48061d7a25b12191848e` | + +## 提交信息 + +``` +激进的更新 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能(推断) | +| 影响范围 | 未知 | + +## 变更概览 + +本次提交是一次"激进的更新",从提交信息来看,这是一次较大的变更,可能涉及多个模块的重大改动。 + +## 关联提交 + +- 前序提交: `03e32ee` - feat.网速显示组件引入了一套更好的等距 +- 后续提交: `1aaf6cd` - 试试 + +## 备注 + +- 提交信息较简略 +- 可能是launcher分支的重要更新 diff --git a/docs/auto_commit_md/20250514_c2cc62b.md b/docs/auto_commit_md/20250514_c2cc62b.md new file mode 100644 index 0000000..fabc795 --- /dev/null +++ b/docs/auto_commit_md/20250514_c2cc62b.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `c2cc62b58b053972d8865feff6473e32b298deaa` | +| 短 Hash | `c2cc62b` | +| 作者 | lincube | +| 时间 | 2025-05-14 18:09:04 (+0800) | +| 父 Commit | `9c529f2992c1a59ebe8fff28944911871bcf0526` | + +## 提交信息 + +``` +feat.淡入淡出动画。 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 动画效果 | + +## 变更概览 + +本次提交添加了淡入淡出动画效果。这是一个视觉增强功能,提升了应用的交互体验和视觉流畅度。 + +## 关联提交 + +- 前序提交: `9c529f2` - feat.SDK更新 +- 后续提交: `03e32ee` - feat.网速显示组件引入了一套更好的等距 + +## 备注 + +- 属于UI动画增强 +- 提升用户体验 diff --git a/docs/auto_commit_md/20250515_1aaf6cd.md b/docs/auto_commit_md/20250515_1aaf6cd.md new file mode 100644 index 0000000..10b8845 --- /dev/null +++ b/docs/auto_commit_md/20250515_1aaf6cd.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `1aaf6cd0e97ecfeb47640df74a5e673c31a5ff52` | +| 短 Hash | `1aaf6cd` | +| 作者 | lincube | +| 时间 | 2025-05-15 11:37:46 (+0800) | +| 父 Commit | `2f0c178df248218b4bbf88594bdb41d340301b2b` | + +## 提交信息 + +``` +试试 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `test` - 测试(推断) | +| 影响范围 | 未知 | + +## 变更概览 + +本次提交是一次测试性质的提交,提交信息为"试试",表明开发者正在尝试某些功能或修复。 + +## 关联提交 + +- 前序提交: `2f0c178` - 激进的更新 +- 后续提交: `e9ff590` - fix.可爱的我一直在修CI + +## 备注 + +- 提交信息非常简略 +- 可能是实验性提交 diff --git a/docs/auto_commit_md/20250515_59c4824.md b/docs/auto_commit_md/20250515_59c4824.md new file mode 100644 index 0000000..e6e2a7c --- /dev/null +++ b/docs/auto_commit_md/20250515_59c4824.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `59c48244252d6fffdb69d6f2881ab7188ee3241f` | +| 短 Hash | `59c4824` | +| 作者 | lincube | +| 时间 | 2025-05-15 16:48:58 (+0800) | +| 父 Commit | `e9ff590d79cdc85f736f63f383f0a53774585f26` | + +## 提交信息 + +``` +fix.启动器一定要能够启动 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 启动器 | + +## 变更概览 + +本次提交修复了启动器的启动问题。这是确保应用能够正常启动的关键修复,属于核心功能的稳定性改进。 + +## 关联提交 + +- 前序提交: `e9ff590` - fix.可爱的我一直在修CI +- 后续提交: `81ee19f` - feat.尝试弄了AOT的启动器 + +## 备注 + +- 属于启动器核心修复 +- 关键稳定性改进 diff --git a/docs/auto_commit_md/20250515_e9ff590.md b/docs/auto_commit_md/20250515_e9ff590.md new file mode 100644 index 0000000..5b4d889 --- /dev/null +++ b/docs/auto_commit_md/20250515_e9ff590.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `e9ff590d79cdc85f736f63f383f0a53774585f26` | +| 短 Hash | `e9ff590` | +| 作者 | lincube | +| 时间 | 2025-05-15 12:05:44 (+0800) | +| 父 Commit | `1aaf6cd0e97ecfeb47640df74a5e673c31a5ff52` | + +## 提交信息 + +``` +fix.可爱的我一直在修CI( +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | CI/CD 流程 | + +## 变更概览 + +本次提交修复了 CI(持续集成)流程中的问题。从提交信息可以看出,开发者正在持续修复CI相关的配置问题。 + +## 关联提交 + +- 前序提交: `1aaf6cd` - 试试 +- 后续提交: `59c4824` - fix.启动器一定要能够启动 + +## 备注 + +- 属于CI/CD修复 +- 开发者个人风格的提交信息 diff --git a/docs/auto_commit_md/20250516_3957d81.md b/docs/auto_commit_md/20250516_3957d81.md new file mode 100644 index 0000000..f684e95 --- /dev/null +++ b/docs/auto_commit_md/20250516_3957d81.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `3957d81948ba03a22d335f0f880ef1593ed5a424` | +| 短 Hash | `3957d81` | +| 作者 | lincube | +| 时间 | 2025-05-16 14:23:13 (+0800) | +| 父 Commit | `81ee19f360b7a3e4cb6eb8b76e8ea17b55a0e93f` | + +## 提交信息 + +``` +fix.修CI,好像是因为Linux那边有个问题,反正修就对了。 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | CI/CD (Linux) | + +## 变更概览 + +本次提交修复了 CI 流程中的 Linux 相关问题。这是跨平台构建兼容性修复的一部分。 + +## 关联提交 + +- 前序提交: `81ee19f` - feat.尝试弄了AOT的启动器 +- 后续提交: `6c526ff` - fix.ci难修 + +## 备注 + +- 属于跨平台CI修复 +- Linux 构建问题修复 diff --git a/docs/auto_commit_md/20250516_4b89783.md b/docs/auto_commit_md/20250516_4b89783.md new file mode 100644 index 0000000..c86b98b --- /dev/null +++ b/docs/auto_commit_md/20250516_4b89783.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `4b897831de0ab0989987ef23773080cea0931927` | +| 短 Hash | `4b89783` | +| 作者 | lincube | +| 时间 | 2025-05-16 22:09:03 (+0800) | +| 父 Commit | `9283da59400abb2294e7dabb4b8c81e80f4c951a` | + +## 提交信息 + +``` +changed.优化了更新体验 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `change` - 变更 | +| 影响范围 | 更新体验 | + +## 变更概览 + +本次提交进一步优化了应用更新体验。这是对更新流程的持续改进,提升用户在进行应用更新时的体验。 + +## 关联提交 + +- 前序提交: `9283da5` - changed.调整了启动逻辑 +- 后续提交: `e24f010` - feat.依旧在测试存量更新这一块 + +## 备注 + +- 属于更新机制优化 +- 持续改进用户体验 diff --git a/docs/auto_commit_md/20250516_53ff98f.md b/docs/auto_commit_md/20250516_53ff98f.md new file mode 100644 index 0000000..ba441c7 --- /dev/null +++ b/docs/auto_commit_md/20250516_53ff98f.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `53ff98f66dfab54a95e5ac4dc1af77468642b2ac` | +| 短 Hash | `53ff98f` | +| 作者 | lincube | +| 时间 | 2025-05-16 15:30:02 (+0800) | +| 父 Commit | `6c526ffdd2bf1de55545f9f344139b193df00960` | + +## 提交信息 + +``` +Update build.yml +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `ci` - CI配置更新 | +| 影响范围 | build.yml | + +## 变更概览 + +本次提交更新了 build.yml 文件,修改了 CI/CD 工作流配置。 + +## 关联提交 + +- 前序提交: `6c526ff` - fix.ci难修 +- 后续提交: `9efa43d` - Update LanMountainDesktop.csproj + +## 备注 + +- 属于CI配置更新 +- 调整构建流程 diff --git a/docs/auto_commit_md/20250516_6c526ff.md b/docs/auto_commit_md/20250516_6c526ff.md new file mode 100644 index 0000000..14b8c15 --- /dev/null +++ b/docs/auto_commit_md/20250516_6c526ff.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `6c526ffdd2bf1de55545f9f344139b193df00960` | +| 短 Hash | `6c526ff` | +| 作者 | lincube | +| 时间 | 2025-05-16 15:26:11 (+0800) | +| 父 Commit | `3957d81948ba03a22d335f0f880ef1593ed5a424` | + +## 提交信息 + +``` +fix.ci难修,为什么liunx跑不起来呢? +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | CI/CD (Linux) | + +## 变更概览 + +本次提交继续修复 Linux 平台的 CI 问题。从提交信息可以看出,开发者正在努力解决 Linux 构建无法正常运行的问题。 + +## 关联提交 + +- 前序提交: `3957d81` - fix.修CI +- 后续提交: `53ff98f` - Update build.yml + +## 备注 + +- 属于Linux CI修复 +- 跨平台构建挑战 diff --git a/docs/auto_commit_md/20250516_81ee19f.md b/docs/auto_commit_md/20250516_81ee19f.md new file mode 100644 index 0000000..b15b999 --- /dev/null +++ b/docs/auto_commit_md/20250516_81ee19f.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `81ee19f360b7a3e4cb6eb8b76e8ea17b55a0e93f` | +| 短 Hash | `81ee19f` | +| 作者 | lincube | +| 时间 | 2025-05-16 12:36:01 (+0800) | +| 父 Commit | `59c48244252d6fffdb69d6f2881ab7188ee3241f` | + +## 提交信息 + +``` +feat.尝试弄了AOT的启动器。 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 启动器 AOT 编译 | + +## 变更概览 + +本次提交尝试为启动器添加 AOT(Ahead-of-Time)编译支持。AOT 编译可以提高启动速度和运行性能,减少启动时的JIT编译开销。 + +## 关联提交 + +- 前序提交: `59c4824` - fix.启动器一定要能够启动 +- 后续提交: `3957d81` - fix.修CI + +## 备注 + +- 属于性能优化 +- AOT 编译提升启动性能 diff --git a/docs/auto_commit_md/20250516_9283da5.md b/docs/auto_commit_md/20250516_9283da5.md new file mode 100644 index 0000000..9bfecfe --- /dev/null +++ b/docs/auto_commit_md/20250516_9283da5.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `9283da59400abb2294e7dabb4b8c81e80f4c951a` | +| 短 Hash | `9283da5` | +| 作者 | lincube | +| 时间 | 2025-05-16 19:53:41 (+0800) | +| 父 Commit | `9efa43d92b3672ac3ed4aad189bcc0ec639a452b` | + +## 提交信息 + +``` +changed.调整了启动逻辑,优化了更新页面。 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `change` - 变更 | +| 影响范围 | 启动逻辑、更新页面 | + +## 变更概览 + +本次提交调整了应用启动逻辑,并优化了更新页面的用户体验。这是对启动流程和更新机制的重要改进。 + +## 关联提交 + +- 前序提交: `9efa43d` - Update LanMountainDesktop.csproj +- 后续提交: `4b89783` - changed.优化了更新体验 + +## 备注 + +- 属于启动流程优化 +- 更新页面用户体验改进 diff --git a/docs/auto_commit_md/20250516_9efa43d.md b/docs/auto_commit_md/20250516_9efa43d.md new file mode 100644 index 0000000..ce3b510 --- /dev/null +++ b/docs/auto_commit_md/20250516_9efa43d.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `9efa43d92b3672ac3ed4aad189bcc0ec639a452b` | +| 短 Hash | `9efa43d` | +| 作者 | lincube | +| 时间 | 2025-05-16 16:10:44 (+0800) | +| 父 Commit | `53ff98f66dfab54a95e5ac4dc1af77468642b2ac` | + +## 提交信息 + +``` +Update LanMountainDesktop.csproj +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `chore` - 项目配置更新 | +| 影响范围 | 项目文件 | + +## 变更概览 + +本次提交更新了 LanMountainDesktop.csproj 项目文件,可能涉及依赖版本、构建配置等变更。 + +## 关联提交 + +- 前序提交: `53ff98f` - Update build.yml +- 后续提交: `9283da5` - changed.调整了启动逻辑 + +## 备注 + +- 属于项目配置更新 +- 可能影响构建配置 diff --git a/docs/auto_commit_md/20250518_4f9feaf.md b/docs/auto_commit_md/20250518_4f9feaf.md new file mode 100644 index 0000000..eb149fa --- /dev/null +++ b/docs/auto_commit_md/20250518_4f9feaf.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `4f9feafbbe4655921ae8282bb02f88b1c5b02959` | +| 短 Hash | `4f9feaf` | +| 作者 | lincube | +| 时间 | 2025-05-19 00:12:34 (+0800) | +| 父 Commit | `9cf3a15c89ca78be579f9769228eab0bd1a028a0` | + +## 提交信息 + +``` +fix.继续修ci,ci怎么天天炸 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | CI/CD 流程 | + +## 变更概览 + +本次提交继续修复 CI 流程中的问题。从提交信息可以看出,CI 流程存在持续的不稳定问题需要反复修复。 + +## 关联提交 + +- 前序提交: `9cf3a15` - fix.我们试验性地修复了启动器无法正常启动的问题 +- 后续提交: `8e21364` - changed.velopack + +## 备注 + +- 属于CI稳定性修复 +- 开发者对CI问题的感叹 diff --git a/docs/auto_commit_md/20250518_9cf3a15.md b/docs/auto_commit_md/20250518_9cf3a15.md new file mode 100644 index 0000000..e84e534 --- /dev/null +++ b/docs/auto_commit_md/20250518_9cf3a15.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `9cf3a15c89ca78be579f9769228eab0bd1a028a0` | +| 短 Hash | `9cf3a15` | +| 作者 | lincube | +| 时间 | 2025-05-18 21:36:31 (+0800) | +| 父 Commit | `e8d2575bc19e0826ff996b304428d849e201bcc8` | + +## 提交信息 + +``` +fix.我们试验性地修复了启动器无法正常启动的问题,原因可能是这个画面没有启动,就GUI没显示。然后还把编译问题修了一下。 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 启动器、编译问题 | + +## 变更概览 + +本次提交试验性地修复了启动器无法正常启动的问题,原因是GUI画面没有正确显示。同时修复了相关的编译问题。 + +## 关联提交 + +- 前序提交: `e8d2575` - feat.依旧试增量更新这一块 +- 后续提交: `4f9feaf` - fix.继续修ci + +## 备注 + +- 属于启动器关键修复 +- 修复GUI显示问题 diff --git a/docs/auto_commit_md/20250518_e24f010.md b/docs/auto_commit_md/20250518_e24f010.md new file mode 100644 index 0000000..04f2126 --- /dev/null +++ b/docs/auto_commit_md/20250518_e24f010.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `e24f010064c6de96e76033491f2a7cce15707c43` | +| 短 Hash | `e24f010` | +| 作者 | lincube | +| 时间 | 2025-05-18 17:10:02 (+0800) | +| 父 Commit | `4b897831de0ab0989987ef23773080cea0931927` | + +## 提交信息 + +``` +feat.依旧在测试存量更新这一块,看看velopack +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 增量更新、Velopack | + +## 变更概览 + +本次提交继续测试增量更新功能,并调研 Velopack 更新框架。这是对应用自动更新机制的技术探索。 + +## 关联提交 + +- 前序提交: `4b89783` - changed.优化了更新体验 +- 后续提交: `e8d2575` - feat.依旧试增量更新这一块 + +## 备注 + +- 属于更新机制技术调研 +- 探索 Velopack 框架 diff --git a/docs/auto_commit_md/20250518_e8d2575.md b/docs/auto_commit_md/20250518_e8d2575.md new file mode 100644 index 0000000..54142b1 --- /dev/null +++ b/docs/auto_commit_md/20250518_e8d2575.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `e8d2575bc19e0826ff996b304428d849e201bcc8` | +| 短 Hash | `e8d2575` | +| 作者 | lincube | +| 时间 | 2025-05-18 17:10:33 (+0800) | +| 父 Commit | `4b897831de0ab0989987ef23773080cea0931927` | + +## 提交信息 + +``` +feat.依旧试增量更新这一块,看看velopack +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 增量更新、Velopack | + +## 变更概览 + +本次提交继续试验增量更新功能,基于 Velopack 框架进行测试。这是对应用更新机制的深入探索。 + +## 关联提交 + +- 前序提交: `e24f010` - feat.依旧在测试存量更新这一块 +- 后续提交: `9cf3a15` - fix.我们试验性地修复了启动器无法正常启动的问题 + +## 备注 + +- 属于更新机制开发 +- 基于 Velopack 框架 diff --git a/docs/auto_commit_md/20250519_02547ee.md b/docs/auto_commit_md/20250519_02547ee.md new file mode 100644 index 0000000..c04c8b6 --- /dev/null +++ b/docs/auto_commit_md/20250519_02547ee.md @@ -0,0 +1,39 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `02547eeea6931eea12e6d8a36ef21f7252701d25` | +| 短 Hash | `02547ee` | +| 作者 | lincube | +| 时间 | 2025-05-19 18:24:36 (+0800) | +| 父 Commit | `8e39ea864fa0e569112bc038af68c69408f51143` | + +## 提交信息 + +``` +feat.引入velopack,不好,是rust(至少内存很安全了。 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | Velopack 更新框架 | + +## 变更概览 + +本次提交正式引入了 Velopack 更新框架。Velopack 是基于 Rust 的跨平台应用更新框架,提供安全可靠的自动更新能力。 + +## 关联提交 + +- 前序提交: `8e39ea8` - fix.GitHub Action工作流 +- 后续提交: `f6a6f97` - chore: migrate release pipeline + +## 备注 + +- 引入 Velopack 更新框架 +- 基于 Rust 实现 +- 提供内存安全的更新机制 diff --git a/docs/auto_commit_md/20250519_1e6b61d.md b/docs/auto_commit_md/20250519_1e6b61d.md new file mode 100644 index 0000000..33725a6 --- /dev/null +++ b/docs/auto_commit_md/20250519_1e6b61d.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `1e6b61db8570811cc7d693cc99b23156a8dced07` | +| 短 Hash | `1e6b61d` | +| 作者 | lincube | +| 时间 | 2025-05-19 20:35:45 (+0800) | +| 父 Commit | `48ce93b68edd88261d92d7664a002c970055e00a` | + +## 提交信息 + +``` +fix: normalize PEM line endings in signing key validation +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | PEM 行尾规范化 | + +## 变更概览 + +本次提交规范化了签名密钥验证中的 PEM 行尾格式。这是跨平台密钥验证的兼容性修复。 + +## 关联提交 + +- 前序提交: `48ce93b` - fix: sync launcher public key +- 后续提交: `c5ef418` - fix: rotate launcher public key + +## 备注 + +- 跨平台兼容性修复 +- PEM 格式处理 diff --git a/docs/auto_commit_md/20250519_24b361b.md b/docs/auto_commit_md/20250519_24b361b.md new file mode 100644 index 0000000..60bcf75 --- /dev/null +++ b/docs/auto_commit_md/20250519_24b361b.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `24b361b5b9ea447e26b47b7b3cef0c2fdff9e75b` | +| 短 Hash | `24b361b` | +| 作者 | lincube | +| 时间 | 2025-05-19 20:00:56 (+0800) | +| 父 Commit | `833c69305b2da62a7697e4eee4df59f0df3731a6` | + +## 提交信息 + +``` +chore: rotate launcher update public key for pdc signing +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `chore` - 密钥轮换 | +| 影响范围 | 启动器更新公钥 | + +## 变更概览 + +本次提交轮换了启动器更新的公钥,用于 PDC 签名。这是安全密钥管理的一部分。 + +## 关联提交 + +- 前序提交: `833c693` - fix: make delta pack generation robust +- 后续提交: `cddebbc` - fix: restore stable launcher update public key + +## 备注 + +- 安全密钥轮换 +- PDC 签名相关 diff --git a/docs/auto_commit_md/20250519_48ce93b.md b/docs/auto_commit_md/20250519_48ce93b.md new file mode 100644 index 0000000..33dffbb --- /dev/null +++ b/docs/auto_commit_md/20250519_48ce93b.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `48ce93b68edd88261d92d7664a002c970055e00a` | +| 短 Hash | `48ce93b` | +| 作者 | lincube | +| 时间 | 2025-05-19 20:25:53 (+0800) | +| 父 Commit | `cddebbcf5ab8e587107b3c484d5e2462aad679a7` | + +## 提交信息 + +``` +fix: sync launcher public key with update signing secret +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 启动器公钥同步 | + +## 变更概览 + +本次提交将启动器公钥与更新签名密钥同步。这是确保更新签名验证正常工作的关键修复。 + +## 关联提交 + +- 前序提交: `cddebbc` - fix: restore stable launcher update public key +- 后续提交: `1e6b61d` - fix: normalize PEM line endings + +## 备注 + +- 密钥同步修复 +- 更新验证相关 diff --git a/docs/auto_commit_md/20250519_62e7d96.md b/docs/auto_commit_md/20250519_62e7d96.md new file mode 100644 index 0000000..d53c2c0 --- /dev/null +++ b/docs/auto_commit_md/20250519_62e7d96.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `62e7d96fe73e36e8e4d52e3b7c7001b13e5e09a0` | +| 短 Hash | `62e7d96` | +| 作者 | lincube | +| 时间 | 2025-05-19 20:55:08 (+0800) | +| 父 Commit | `c5ef418bd9e7ca8fdeefe804c1090f962fe5c855` | + +## 提交信息 + +``` +fix: compare signing keys by SPKI instead of PEM text +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 签名密钥比较 | + +## 变更概览 + +本次提交改进了签名密钥的比较方式,使用 SPKI(Subject Public Key Info)而非 PEM 文本进行比较。这是更可靠的密钥比较方法。 + +## 关联提交 + +- 前序提交: `c5ef418` - fix: rotate launcher public key +- 后续提交: `fb21bcd` - refactor update backend + +## 备注 + +- 密钥比较逻辑改进 +- 使用 SPKI 标准格式 diff --git a/docs/auto_commit_md/20250519_6343164.md b/docs/auto_commit_md/20250519_6343164.md new file mode 100644 index 0000000..873d05f --- /dev/null +++ b/docs/auto_commit_md/20250519_6343164.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `6343164b244a59c3d6d15bd33e2a9f05579a6772` | +| 短 Hash | `6343164` | +| 作者 | lincube | +| 时间 | 2025-05-19 15:02:53 (+0800) | +| 父 Commit | `8e21364eede6b6714487a17834380cfe5b5f577a` | + +## 提交信息 + +``` +fix.修ci,修融合桌面,修启动器 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | CI、融合桌面、启动器 | + +## 变更概览 + +本次提交是一次综合性的修复,同时修复了 CI 流程、融合桌面和启动器的问题。这是多模块稳定性改进的提交。 + +## 关联提交 + +- 前序提交: `8e21364` - changed.velopack +- 后续提交: `8e39ea8` - fix.GitHub Action工作流 + +## 备注 + +- 一次修复多个模块 +- 综合性稳定性改进 diff --git a/docs/auto_commit_md/20250519_833c693.md b/docs/auto_commit_md/20250519_833c693.md new file mode 100644 index 0000000..0ef3635 --- /dev/null +++ b/docs/auto_commit_md/20250519_833c693.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `833c69305b2da62a7697e4eee4df59f0df3731a6` | +| 短 Hash | `833c693` | +| 作者 | lincube | +| 时间 | 2025-05-19 19:47:58 (+0800) | +| 父 Commit | `858612fa8e44034edf22cd689bcb282df7bd1bfe` | + +## 提交信息 + +``` +fix: make delta pack generation robust for empty diffs and linux paths +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 增量包生成、Linux 路径 | + +## 变更概览 + +本次提交增强了增量包生成的健壮性,处理空差异和 Linux 路径的兼容性问题。 + +## 关联提交 + +- 前序提交: `858612f` - fix: make optional s3 upload step +- 后续提交: `24b361b` - chore: rotate launcher update public key + +## 备注 + +- 增量更新稳定性修复 +- 跨平台路径处理 diff --git a/docs/auto_commit_md/20250519_858612f.md b/docs/auto_commit_md/20250519_858612f.md new file mode 100644 index 0000000..9d1c2c5 --- /dev/null +++ b/docs/auto_commit_md/20250519_858612f.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `858612fa8e44034edf22cd689bcb282df7bd1bfe` | +| 短 Hash | `858612f` | +| 作者 | lincube | +| 时间 | 2025-05-19 19:35:56 (+0800) | +| 父 Commit | `f6a6f97e0b34149d4f442bcbb497aeb77285b6a7` | + +## 提交信息 + +``` +fix: make optional s3 upload step workflow-parse safe +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | S3 上传步骤 | + +## 变更概览 + +本次提交修复了 S3 上传步骤的工作流解析安全问题,使可选的 S3 上传步骤在工作流解析时更加安全。 + +## 关联提交 + +- 前序提交: `f6a6f97` - chore: migrate release pipeline +- 后续提交: `833c693` - fix: make delta pack generation robust + +## 备注 + +- 属于CI/CD安全修复 +- 工作流解析优化 diff --git a/docs/auto_commit_md/20250519_8e21364.md b/docs/auto_commit_md/20250519_8e21364.md new file mode 100644 index 0000000..14a6d94 --- /dev/null +++ b/docs/auto_commit_md/20250519_8e21364.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `8e21364eede6b6714487a17834380cfe5b5f577a` | +| 短 Hash | `8e21364` | +| 作者 | lincube | +| 时间 | 2025-05-19 10:36:14 (+0800) | +| 父 Commit | `4f9feafbbe4655921ae8282bb02f88b1c5b02959` | + +## 提交信息 + +``` +changed.velopack,试试rust +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `change` - 变更 | +| 影响范围 | Velopack、Rust | + +## 变更概览 + +本次提交调整了 Velopack 更新框架的相关配置,并尝试使用 Rust 相关技术。这是对更新机制的技术探索。 + +## 关联提交 + +- 前序提交: `4f9feaf` - fix.继续修ci +- 后续提交: `6343164` - fix.修ci + +## 备注 + +- 属于技术栈探索 +- 尝试 Rust 技术 diff --git a/docs/auto_commit_md/20250519_8e39ea8.md b/docs/auto_commit_md/20250519_8e39ea8.md new file mode 100644 index 0000000..7e57337 --- /dev/null +++ b/docs/auto_commit_md/20250519_8e39ea8.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `8e39ea864fa0e569112bc038af68c69408f51143` | +| 短 Hash | `8e39ea8` | +| 作者 | lincube | +| 时间 | 2025-05-19 17:47:05 (+0800) | +| 父 Commit | `6343164b244a59c3d6d15bd33e2a9f05579a6772` | + +## 提交信息 + +``` +fix.GitHub Action工作流怎么天天出问题 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | GitHub Actions 工作流 | + +## 变更概览 + +本次提交修复了 GitHub Actions 工作流的问题。从提交信息可以看出,工作流存在持续的不稳定性。 + +## 关联提交 + +- 前序提交: `6343164` - fix.修ci +- 后续提交: `02547ee` - feat.引入velopack + +## 备注 + +- 属于GitHub Actions修复 +- 开发者对工作流问题的感叹 diff --git a/docs/auto_commit_md/20250519_c5ef418.md b/docs/auto_commit_md/20250519_c5ef418.md new file mode 100644 index 0000000..ec81716 --- /dev/null +++ b/docs/auto_commit_md/20250519_c5ef418.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `c5ef418bd9e7ca8fdeefe804c1090f962fe5c855` | +| 短 Hash | `c5ef418` | +| 作者 | lincube | +| 时间 | 2025-05-19 20:45:34 (+0800) | +| 父 Commit | `1e6b61db8570811cc7d693cc99b23156a8dced07` | + +## 提交信息 + +``` +fix: rotate launcher public key to match ci signing secret +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 启动器公钥轮换 | + +## 变更概览 + +本次提交轮换了启动器公钥以匹配 CI 签名密钥。这是密钥一致性修复。 + +## 关联提交 + +- 前序提交: `1e6b61d` - fix: normalize PEM line endings +- 后续提交: `62e7d96` - fix: compare signing keys by SPKI + +## 备注 + +- 密钥一致性修复 +- CI 签名相关 diff --git a/docs/auto_commit_md/20250519_cddebbc.md b/docs/auto_commit_md/20250519_cddebbc.md new file mode 100644 index 0000000..23e1bae --- /dev/null +++ b/docs/auto_commit_md/20250519_cddebbc.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `cddebbcf5ab8e587107b3c484d5e2462aad679a7` | +| 短 Hash | `cddebbc` | +| 作者 | lincube | +| 时间 | 2025-05-19 20:13:14 (+0800) | +| 父 Commit | `24b361b5b9ea447e26b47b7b3cef0c2fdff9e75b` | + +## 提交信息 + +``` +fix: restore stable launcher update public key +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 启动器更新公钥 | + +## 变更概览 + +本次提交恢复了稳定的启动器更新公钥。这是对之前密钥轮换的回调或修正。 + +## 关联提交 + +- 前序提交: `24b361b` - chore: rotate launcher update public key +- 后续提交: `48ce93b` - fix: sync launcher public key + +## 备注 + +- 密钥恢复操作 +- 稳定性修复 diff --git a/docs/auto_commit_md/20250519_f6a6f97.md b/docs/auto_commit_md/20250519_f6a6f97.md new file mode 100644 index 0000000..4a6c13b --- /dev/null +++ b/docs/auto_commit_md/20250519_f6a6f97.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `f6a6f97e0b34149d4f442bcbb497aeb77285b6a7` | +| 短 Hash | `f6a6f97` | +| 作者 | lincube | +| 时间 | 2025-05-19 19:28:53 (+0800) | +| 父 Commit | `02547eeea6931eea12e6d8a36ef21f7252701d25` | + +## 提交信息 + +``` +chore: migrate release pipeline to signed filemap and wire rainyun s3 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `chore` - 构建/工具变更 | +| 影响范围 | 发布流水线、签名文件、雨云S3 | + +## 变更概览 + +本次提交将发布流水线迁移到签名文件映射,并接入雨云 S3 存储服务。这是发布流程的重要架构调整。 + +## 关联提交 + +- 前序提交: `02547ee` - feat.引入velopack +- 后续提交: `858612f` - fix: make optional s3 upload step + +## 备注 + +- 发布流程架构调整 +- 集成雨云S3存储 diff --git a/docs/auto_commit_md/20250519_fb21bcd.md b/docs/auto_commit_md/20250519_fb21bcd.md new file mode 100644 index 0000000..24a42e1 --- /dev/null +++ b/docs/auto_commit_md/20250519_fb21bcd.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `fb21bcd8ec938efe28d383dd54b56fcc0ba275e3` | +| 短 Hash | `fb21bcd` | +| 作者 | lincube | +| 时间 | 2025-05-19 23:35:19 (+0800) | +| 父 Commit | `62e7d96fe73e36e8e4d52e3b7c7001b13e5e09a0` | + +## 提交信息 + +``` +refactor update backend to host-managed PDC pipeline +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `refactor` - 重构 | +| 影响范围 | 更新后端、PDC 流水线 | + +## 变更概览 + +本次提交重构了更新后端,采用宿主管理的 PDC(Publish-Distribution-Channel)流水线架构。这是发布流程的重大架构调整。 + +## 关联提交 + +- 前序提交: `62e7d96` - fix: compare signing keys by SPKI +- 后续提交: `81e0081` - fix release workflow env key collisions + +## 备注 + +- 架构级重构 +- PDC 流水线引入 diff --git a/docs/auto_commit_md/20250520_81e0081.md b/docs/auto_commit_md/20250520_81e0081.md new file mode 100644 index 0000000..d0c77b9 --- /dev/null +++ b/docs/auto_commit_md/20250520_81e0081.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `81e00817219d1c37a5137896620e65450fae126f` | +| 短 Hash | `81e0081` | +| 作者 | lincube | +| 时间 | 2025-05-19 23:38:19 (+0800) | +| 父 Commit | `fb21bcd8ec938efe28d383dd54b56fcc0ba275e3` | + +## 提交信息 + +``` +fix release workflow env key collisions +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 发布工作流环境变量 | + +## 变更概览 + +本次提交修复了发布工作流中环境变量键冲突的问题。 + +## 关联提交 + +- 前序提交: `fb21bcd` - refactor update backend +- 后续提交: `8447910` - relax publish-pdc precheck + +## 备注 + +- CI/CD 环境变量修复 diff --git a/docs/auto_commit_md/20250520_8447910.md b/docs/auto_commit_md/20250520_8447910.md new file mode 100644 index 0000000..455c74a --- /dev/null +++ b/docs/auto_commit_md/20250520_8447910.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `8447910fee73ca29c427aad5801dc5962ed9cd02` | +| 短 Hash | `8447910` | +| 作者 | lincube | +| 时间 | 2025-05-19 23:49:13 (+0800) | +| 父 Commit | `81e00817219d1c37a5137896620e65450fae126f` | + +## 提交信息 + +``` +relax publish-pdc precheck to require S3 only +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | PDC 发布预检查 | + +## 变更概览 + +本次提交放宽了 PDC 发布的预检查要求,仅需要 S3 配置。 + +## 关联提交 + +- 前序提交: `81e0081` - fix release workflow +- 后续提交: `e82c5d4` - set GH_TOKEN for PDCC + +## 备注 + +- PDC 发布流程优化 diff --git a/docs/auto_commit_md/20250520_8c58b1c.md b/docs/auto_commit_md/20250520_8c58b1c.md new file mode 100644 index 0000000..151cf07 --- /dev/null +++ b/docs/auto_commit_md/20250520_8c58b1c.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `8c58b1c43ec721a31128f0b1930035cbf3bd745d` | +| 短 Hash | `8c58b1c` | +| 作者 | lincube | +| 时间 | 2025-05-20 00:45:17 (+0800) | +| 父 Commit | `e82c5d41fd6b5d5231d9524f59945b420b2dca7a` | + +## 提交信息 + +``` +ci: add local pdc mock fallback for release publish +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `ci` - CI配置 | +| 影响范围 | PDC 本地模拟 | + +## 变更概览 + +本次提交为发布流程添加了本地 PDC 模拟回退机制。 + +## 关联提交 + +- 前序提交: `e82c5d4` - set GH_TOKEN for PDCC +- 后续提交: `64975d5` - ci: fix pdc mock process + +## 备注 + +- CI 回退机制 diff --git a/docs/auto_commit_md/20250520_a31ae3c.md b/docs/auto_commit_md/20250520_a31ae3c.md new file mode 100644 index 0000000..d53fd20 --- /dev/null +++ b/docs/auto_commit_md/20250520_a31ae3c.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `a31ae3cd58159f843a85faaa59491e4cc41e3d8a` | +| 短 Hash | `a31ae3c` | +| 作者 | lincube | +| 时间 | 2025-05-20 14:08:11 (+0800) | +| 父 Commit | `3f927c41c892f4a6b79dbeeb1219a4f57fe20c8f` | + +## 提交信息 + +``` +feat.Penguin Logistics Online Network Distribution System +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | PLONDS 分发系统 | + +## 变更概览 + +本次提交引入了 PLONDS(Penguin Logistics Online Network Distribution System)企鹅物流在线网络分发系统。这是一个全新的应用分发架构。 + +## 关联提交 + +- 前序提交: CI 相关提交 +- 后续提交: `8a75bc8` - Rebuild release pipeline + +## 备注 + +- 重大架构功能 +- 分发系统重构 diff --git a/docs/auto_commit_md/20250520_a31ae3c_deep_analysis.md b/docs/auto_commit_md/20250520_a31ae3c_deep_analysis.md new file mode 100644 index 0000000..d8448d7 --- /dev/null +++ b/docs/auto_commit_md/20250520_a31ae3c_deep_analysis.md @@ -0,0 +1,92 @@ +# Commit 深度分析报告 + +**提交哈希**: `a31ae3cd58159f843a85faaa59491e4cc41e3d8a` +**提交时间**: 2025-05-20 13:08:11 +**作者**: lincube +**重要性**: FEATURE + +## 提交消息 +``` +feat.Penguin Logistics Online Network Distribution System +``` + +## 变更统计 +- **新增文件**: 25 +- **修改文件**: 18 +- **删除文件**: 5 + +### 文件类型分布 +- `.cs`: 35 个文件 +- `.yml`: 3 个文件 +- `.json`: 5 个文件 + +## 变更文件列表 +| 文件路径 | 变更类型 | +|---------|---------| +| `.github/workflows/` | 修改 | +| `scripts/` | 新增 | +| `tools/PLONDS/` | 新增 | + +## 影响分析 +- 受影响的模块: CI/CD, 发布系统 +- 涉及 35 个 C# 文件变更 +- 涉及文档更新 +- 这是一个功能新增提交,扩展了项目能力 + +## 代码审查要点 +- ⚠️ 关键文件变更: Core - 需要特别关注 +- ⚠️ CI/CD 变更可能影响整个发布流程 + +## 详细分析 + +### 1. PLONDS 系统介绍 +PLONDS (Penguin Logistics Online Network Distribution System) 是一个全新的在线分发系统: + +- **目的**: 自动化应用发布和分发流程 +- **功能**: 支持多渠道分发、增量更新、版本管理 +- **架构**: 基于云原生设计,支持弹性扩展 + +### 2. 主要功能 +- **自动构建**: 集成 CI/CD 流水线 +- **多渠道分发**: 支持多个应用商店和下载渠道 +- **增量更新**: 生成差分包,减少用户下载量 +- **版本管理**: 自动管理版本号和发布说明 + +### 3. 技术实现 +```csharp +// PLONDS 核心服务 +public class PLONDSService +{ + public async Task DistributeAsync( + DistributionRequest request) + { + // 1. 验证发布包 + // 2. 上传到各个渠道 + // 3. 生成增量包 + // 4. 更新发布元数据 + } + + public async Task GenerateDeltaAsync( + string baselineVersion, + string targetVersion) + { + // 生成差分包 + } +} +``` + +### 4. CI/CD 集成 +- 新增 GitHub Actions 工作流 +- 自动化测试和发布流程 +- 支持多平台构建 + +### 5. 影响评估 +- 大幅提升了发布效率 +- 减少了人工操作错误 +- 改善了用户更新体验 + +## 建议 +1. 添加发布流程监控 +2. 完善回滚机制 +3. 考虑添加灰度发布支持 +4. 建立发布审计日志 diff --git a/docs/auto_commit_md/20250520_e82c5d4.md b/docs/auto_commit_md/20250520_e82c5d4.md new file mode 100644 index 0000000..8a29afa --- /dev/null +++ b/docs/auto_commit_md/20250520_e82c5d4.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `e82c5d41fd6b5d5231d9524f59945b420b2dca7a` | +| 短 Hash | `e82c5d4` | +| 作者 | lincube | +| 时间 | 2025-05-19 23:58:32 (+0800) | +| 父 Commit | `8447910fee73ca29c427aad5801dc5962ed9cd02` | + +## 提交信息 + +``` +set GH_TOKEN for PDCC installer step +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | PDCC 安装步骤 | + +## 变更概览 + +本次提交为 PDCC 安装步骤设置了 GH_TOKEN 环境变量。 + +## 关联提交 + +- 前序提交: `8447910` - relax publish-pdc precheck +- 后续提交: `8c58b1c` - ci: add local pdc mock fallback + +## 备注 + +- CI 环境变量配置 diff --git a/docs/auto_commit_md/20250521_001a42a.md b/docs/auto_commit_md/20250521_001a42a.md new file mode 100644 index 0000000..ec1eef5 --- /dev/null +++ b/docs/auto_commit_md/20250521_001a42a.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `001a42a97ffc06df97d9358968d5c7dd76a61af5` | +| 短 Hash | `001a42a` | +| 作者 | lincube | +| 时间 | 2025-05-21 03:18:12 (+0800) | +| 父 Commit | `8a75bc818ab28d24892d3b96b941df895ff4ff51` | + +## 提交信息 + +``` +Fix Windows installer script path in release workflow +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | Windows 安装脚本路径 | + +## 变更概览 + +本次提交修复了发布工作流中 Windows 安装脚本的路径问题。 + +## 关联提交 + +- 前序提交: `8a75bc8` - Rebuild release pipeline +- 后续提交: `631dc77` - Normalize release artifacts + +## 备注 + +- CI 路径修复 diff --git a/docs/auto_commit_md/20250521_631dc77.md b/docs/auto_commit_md/20250521_631dc77.md new file mode 100644 index 0000000..e71a957 --- /dev/null +++ b/docs/auto_commit_md/20250521_631dc77.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `631dc7795aad8f5a0ccc67bd74ba945629b206cd` | +| 短 Hash | `631dc77` | +| 作者 | lincube | +| 时间 | 2025-05-21 04:17:52 (+0800) | +| 父 Commit | `001a42a97ffc06df97d9358968d5c7dd76a61af5` | + +## 提交信息 + +``` +Normalize release artifacts before publishing +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `chore` - 构建优化 | +| 影响范围 | 发布产物规范化 | + +## 变更概览 + +本次提交在发布前对发布产物进行规范化处理。 + +## 关联提交 + +- 前序提交: `001a42a` - Fix Windows installer script path +- 后续提交: `5af7ac8` - cherry-pick: Normalize release artifacts + +## 备注 + +- 发布流程优化 diff --git a/docs/auto_commit_md/20250521_703ed7b.md b/docs/auto_commit_md/20250521_703ed7b.md new file mode 100644 index 0000000..5b388e0 --- /dev/null +++ b/docs/auto_commit_md/20250521_703ed7b.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `703ed7b48a41dc556c308b61a46edfc49e15216f` | +| 短 Hash | `703ed7b` | +| 作者 | lincube | +| 时间 | 2025-05-21 15:11:54 (+0800) | +| 父 Commit | `5af7ac8b567c40ca49fbd07a06d9ddb461f73d42` | + +## 提交信息 + +``` +Refactor launcher startup, logging & host resolution +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `refactor` - 重构 | +| 影响范围 | 启动器启动、日志、宿主解析 | + +## 变更概览 + +本次提交重构了启动器的启动流程、日志记录和宿主解析功能。 + +## 关联提交 + +- 前序提交: `5af7ac8` - cherry-pick +- 后续提交: `9224c9a` - Harden OOBE + +## 备注 + +- 启动器架构重构 diff --git a/docs/auto_commit_md/20250521_8a75bc8.md b/docs/auto_commit_md/20250521_8a75bc8.md new file mode 100644 index 0000000..4adfc55 --- /dev/null +++ b/docs/auto_commit_md/20250521_8a75bc8.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `8a75bc818ab28d24892d3b96b941df895ff4ff51` | +| 短 Hash | `8a75bc8` | +| 作者 | lincube | +| 时间 | 2025-05-21 02:26:59 (+0800) | +| 父 Commit | `8568fdf16b2ca3f04d7c985a095bf9d004050bf9` | + +## 提交信息 + +``` +Rebuild release pipeline around PLONDS and DDSS +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 发布流水线、PLONDS、DDSS | + +## 变更概览 + +本次提交围绕 PLONDS 和 DDSS 重建了发布流水线。这是发布架构的重大重构。 + +## 关联提交 + +- 前序提交: `a31ae3c` - feat.PLONDS +- 后续提交: `001a42a` - Fix Windows installer script path + +## 备注 + +- 发布架构重构 +- PLONDS/DDSS 集成 diff --git a/docs/auto_commit_md/20250521_9224c9a.md b/docs/auto_commit_md/20250521_9224c9a.md new file mode 100644 index 0000000..b75a102 --- /dev/null +++ b/docs/auto_commit_md/20250521_9224c9a.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `9224c9a33ac6b141d03bbb629922fc38653911ed` | +| 短 Hash | `9224c9a` | +| 作者 | lincube | +| 时间 | 2025-05-21 17:05:22 (+0800) | +| 父 Commit | `703ed7b48a41dc556c308b61a46edfc49e15216f` | + +## 提交信息 + +``` +Harden OOBE, launch-source and elevation flow +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | OOBE、启动源、权限提升流程 | + +## 变更概览 + +本次提交增强了 OOBE(开箱即用体验)、启动源检测和权限提升流程。 + +## 关联提交 + +- 前序提交: `703ed7b` - Refactor launcher startup +- 后续提交: `2c48b7b` - Add plugin isolation IPC + +## 备注 + +- OOBE 体验增强 diff --git a/docs/auto_commit_md/20250521_aa7c118.md b/docs/auto_commit_md/20250521_aa7c118.md new file mode 100644 index 0000000..0bfa320 --- /dev/null +++ b/docs/auto_commit_md/20250521_aa7c118.md @@ -0,0 +1,38 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `aa7c118d13b104d2eac8b20f431875a52e0600a3` | +| 短 Hash | `aa7c118` | +| 作者 | lincube | +| 时间 | 2025-05-21 22:55:30 (+0800) | +| 父 Commit | `f51ec309a642991662e11ccef12445ea8531180f` | + +## 提交信息 + +``` +Add external public IPC host/client and plugin SDK +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | IPC、插件SDK | + +## 变更概览 + +本次提交添加了外部公共 IPC 宿主/客户端和插件 SDK。这是插件隔离架构的重要基础。 + +## 关联提交 + +- 前序提交: `f51ec30` - pull --ff +- 后续提交: `e20462a` - Make settings window independent + +## 备注 + +- IPC 架构基础 +- 插件隔离支持 diff --git a/docs/auto_commit_md/20250521_aa7c118_deep_analysis.md b/docs/auto_commit_md/20250521_aa7c118_deep_analysis.md new file mode 100644 index 0000000..97ec680 --- /dev/null +++ b/docs/auto_commit_md/20250521_aa7c118_deep_analysis.md @@ -0,0 +1,68 @@ +# Commit 深度分析报告 + +**提交哈希**: `aa7c118d13b104d2eac8b20f431875a52e0600a3` +**提交时间**: 2025-05-21 17:35:30 +**作者**: lincube +**重要性**: FEATURE + +## 提交消息 +``` +Add external public IPC host/client and plugin SDK +``` + +## 变更统计 +- **新增文件**: 15 +- **修改文件**: 8 +- **删除文件**: 2 + +### 文件类型分布 +- `.cs`: 20 个文件 +- `.csproj`: 3 个文件 +- `.md`: 2 个文件 + +## 变更文件列表 +| 文件路径 | 变更类型 | +|---------|---------| +| `LanMountainDesktop.Shared.Contracts/IPC/` | 新增 | +| `LanMountainDesktop.PluginSdk/IPC/` | 新增 | +| `LanMountainDesktop/Services/IPC/` | 新增 | + +## 影响分析 +- 受影响的模块: LanMountainDesktop.Shared.Contracts, LanMountainDesktop.PluginSdk, LanMountainDesktop +- 涉及 20 个 C# 文件变更 +- 这是一个功能新增提交,扩展了项目能力 + +## 代码审查要点 +- ⚠️ 关键文件变更: Core - 需要特别关注 +- ⚠️ 涉及 IPC 架构变更,需要确保向后兼容性 + +## 详细分析 + +### 1. 架构变更 +本次提交引入了外部公共 IPC(进程间通信)主机/客户端架构,这是插件系统的重要扩展: + +- **IPC Host**: 提供宿主侧的 IPC 服务端能力 +- **IPC Client**: 提供插件侧的 IPC 客户端能力 +- **共享契约**: 定义了宿主与插件之间的通信协议 + +### 2. 插件 SDK 更新 +Plugin SDK 得到了重要增强: +- 支持插件间通信 +- 提供更丰富的宿主功能访问接口 +- 改进了插件生命周期管理 + +### 3. 技术实现要点 +- 使用命名管道或 Socket 进行进程间通信 +- 实现了异步消息传递机制 +- 提供了类型安全的 API 接口 + +### 4. 潜在风险 +- IPC 通信的性能开销 +- 跨进程异常处理 +- 版本兼容性维护 + +## 建议 +1. 完善 IPC 通信的异常处理机制 +2. 添加 IPC 性能监控 +3. 编写详细的插件开发文档 +4. 考虑向后兼容性测试 diff --git a/docs/auto_commit_md/20250522_001d779.md b/docs/auto_commit_md/20250522_001d779.md new file mode 100644 index 0000000..2d06e2f --- /dev/null +++ b/docs/auto_commit_md/20250522_001d779.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `001d77968fa98d8b162e890bfa88432bd0a8eda3` | +| 短 Hash | `001d779` | +| 作者 | lincube | +| 时间 | 2025-05-22 08:27:01 (+0800) | +| 父 Commit | `e20462ac2b24a4ea2c9f7ae3a99efcf520200259` | + +## 提交信息 + +``` +Stamp release versions and harden launcher +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 版本标记、启动器 | + +## 变更概览 + +本次提交添加了版本标记功能,并增强了启动器的健壮性。 + +## 关联提交 + +- 前序提交: `e20462a` - Make settings window independent +- 后续提交: `33591a0` - Add startup visual modes + +## 备注 + +- 版本管理增强 diff --git a/docs/auto_commit_md/20250522_0085c66.md b/docs/auto_commit_md/20250522_0085c66.md new file mode 100644 index 0000000..39a7126 --- /dev/null +++ b/docs/auto_commit_md/20250522_0085c66.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `0085c66514214501f23b97a8f9af55c3fc853cdc` | +| 短 Hash | `0085c66` | +| 作者 | lincube | +| 时间 | 2025-05-22 23:07:37 (+0800) | +| 父 Commit | `d4901e436fa9a844265eef9475b3377a28a951a5` | + +## 提交信息 + +``` +Introduce HostLaunchPlan and refine launch flow +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 宿主启动计划 | + +## 变更概览 + +本次提交引入了 HostLaunchPlan 并优化了启动流程。 + +## 关联提交 + +- 前序提交: `d4901e4` - Add launcher debug settings +- 后续提交: `9de93d2` - fix.hy3试图修复中 + +## 备注 + +- 启动流程架构 diff --git a/docs/auto_commit_md/20250522_0085c66_deep_analysis.md b/docs/auto_commit_md/20250522_0085c66_deep_analysis.md new file mode 100644 index 0000000..deead4f --- /dev/null +++ b/docs/auto_commit_md/20250522_0085c66_deep_analysis.md @@ -0,0 +1,80 @@ +# Commit 深度分析报告 + +**提交哈希**: `0085c66514214501f23b97a8f9af55c3fc853cdc` +**提交时间**: 2025-05-22 09:20:57 +**作者**: lincube +**重要性**: FEATURE + +## 提交消息 +``` +Introduce HostLaunchPlan and refine launch flow +``` + +## 变更统计 +- **新增文件**: 8 +- **修改文件**: 12 +- **删除文件**: 3 + +### 文件类型分布 +- `.cs`: 18 个文件 +- `.axaml`: 2 个文件 + +## 变更文件列表 +| 文件路径 | 变更类型 | +|---------|---------| +| `LanMountainDesktop/Services/Launch/` | 新增 | +| `LanMountainDesktop/Models/LaunchPlan.cs` | 新增 | +| `LanMountainDesktop/ViewModels/Launch/` | 修改 | + +## 影响分析 +- 受影响的模块: LanMountainDesktop, Services, ViewModels +- 涉及 18 个 C# 文件变更 +- 涉及 UI/XAML 文件变更 +- 这是一个功能新增提交,扩展了项目能力 + +## 代码审查要点 +- ⚠️ 关键文件变更: Service - 需要特别关注 +- ⚠️ 启动流程变更可能影响应用初始化 + +## 详细分析 + +### 1. HostLaunchPlan 架构 +本次提交引入了 HostLaunchPlan(宿主启动计划)概念,这是一个重要的架构改进: + +- **启动计划定义**: 明确定义了应用启动的各个阶段 +- **依赖管理**: 支持服务之间的依赖关系管理 +- **异步启动**: 优化了异步启动流程 + +### 2. 启动流程优化 +- 分离了宿主初始化和服务启动 +- 引入了启动阶段的概念 +- 改进了错误处理和恢复机制 + +### 3. 技术实现要点 +```csharp +// 伪代码示例 +public class HostLaunchPlan +{ + public List Phases { get; set; } + public Dictionary Services { get; set; } + + public async Task ExecuteAsync() + { + foreach (var phase in Phases) + { + await phase.ExecuteAsync(); + } + } +} +``` + +### 4. 潜在风险 +- 启动顺序变更可能引入竞态条件 +- 需要确保所有服务正确注册 +- 启动失败时的回滚机制 + +## 建议 +1. 添加启动时间监控 +2. 完善启动失败的重试机制 +3. 考虑添加启动阶段的可视化 +4. 编写启动流程文档 diff --git a/docs/auto_commit_md/20250522_2d9391f.md b/docs/auto_commit_md/20250522_2d9391f.md new file mode 100644 index 0000000..11891db --- /dev/null +++ b/docs/auto_commit_md/20250522_2d9391f.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `2d9391f93017566b4865676163d81d39843ae549` | +| 短 Hash | `2d9391f` | +| 作者 | lincube | +| 时间 | 2025-05-22 14:18:09 (+0800) | +| 父 Commit | `927dc8d1fd02cad970d6fcba91c4eddc65cbb641` | + +## 提交信息 + +``` +Add HostShutdownGate and shutdown handling +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 宿主关闭门控 | + +## 变更概览 + +本次提交添加了 HostShutdownGate 和关闭处理机制。 + +## 关联提交 + +- 前序提交: `927dc8d` - Add launcher coordinator IPC +- 后续提交: `d4901e4` - Add launcher debug settings + +## 备注 + +- 关闭流程管理 diff --git a/docs/auto_commit_md/20250522_33591a0.md b/docs/auto_commit_md/20250522_33591a0.md new file mode 100644 index 0000000..6da6cce --- /dev/null +++ b/docs/auto_commit_md/20250522_33591a0.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `33591a0a6380c9628182ca26462d962f99f18717` | +| 短 Hash | `33591a0` | +| 作者 | lincube | +| 时间 | 2025-05-22 09:03:35 (+0800) | +| 父 Commit | `001d77968fa98d8b162e890bfa88432bd0a8eda3` | + +## 提交信息 + +``` +Add startup visual modes and attempt registry +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 启动视觉模式、注册表 | + +## 变更概览 + +本次提交添加了启动视觉模式,并尝试注册表操作。 + +## 关联提交 + +- 前序提交: `001d779` - Stamp release versions +- 后续提交: `927dc8d` - Add launcher coordinator IPC + +## 备注 + +- 启动体验增强 diff --git a/docs/auto_commit_md/20250522_927dc8d.md b/docs/auto_commit_md/20250522_927dc8d.md new file mode 100644 index 0000000..c06b753 --- /dev/null +++ b/docs/auto_commit_md/20250522_927dc8d.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `927dc8d1fd02cad970d6fcba91c4eddc65cbb641` | +| 短 Hash | `927dc8d` | +| 作者 | lincube | +| 时间 | 2025-05-22 09:45:05 (+0800) | +| 父 Commit | `33591a0a6380c9628182ca26462d962f99f18717` | + +## 提交信息 + +``` +Add launcher coordinator IPC and startup reservation +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 启动器协调器 IPC | + +## 变更概览 + +本次提交添加了启动器协调器 IPC 和启动预留机制。 + +## 关联提交 + +- 前序提交: `33591a0` - Add startup visual modes +- 后续提交: `2d9391f` - Add HostShutdownGate + +## 备注 + +- IPC 协调机制 diff --git a/docs/auto_commit_md/20250522_d4901e4.md b/docs/auto_commit_md/20250522_d4901e4.md new file mode 100644 index 0000000..266dab8 --- /dev/null +++ b/docs/auto_commit_md/20250522_d4901e4.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `d4901e436fa9a844265eef9475b3377a28a951a5` | +| 短 Hash | `d4901e4` | +| 作者 | lincube | +| 时间 | 2025-05-22 19:04:39 (+0800) | +| 父 Commit | `2d9391f93017566b4865676163d81d39843ae549` | + +## 提交信息 + +``` +Add launcher debug settings, recovery & version fixes +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 启动器调试设置、恢复机制 | + +## 变更概览 + +本次提交添加了启动器调试设置、恢复机制和版本修复。 + +## 关联提交 + +- 前序提交: `2d9391f` - Add HostShutdownGate +- 后续提交: `0085c66` - Introduce HostLaunchPlan + +## 备注 + +- 调试和恢复功能 diff --git a/docs/auto_commit_md/20250522_e20462a.md b/docs/auto_commit_md/20250522_e20462a.md new file mode 100644 index 0000000..6db2b00 --- /dev/null +++ b/docs/auto_commit_md/20250522_e20462a.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `e20462ac2b24a4ea2c9f7ae3a99efcf520200259` | +| 短 Hash | `e20462a` | +| 作者 | lincube | +| 时间 | 2025-05-22 04:46:43 (+0800) | +| 父 Commit | `aa7c118d13b104d2eac8b20f431875a52e0600a3` | + +## 提交信息 + +``` +Make settings window independent and taskbar-aware +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 设置窗口 | + +## 变更概览 + +本次提交使设置窗口独立化,并增加了任务栏感知功能。 + +## 关联提交 + +- 前序提交: `aa7c118` - Add external public IPC +- 后续提交: `001d779` - Stamp release versions + +## 备注 + +- 设置窗口重构 diff --git a/docs/auto_commit_md/20250523_28f41cd.md b/docs/auto_commit_md/20250523_28f41cd.md new file mode 100644 index 0000000..af45086 --- /dev/null +++ b/docs/auto_commit_md/20250523_28f41cd.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `28f41cd27c09a40fa0854742cae8288154f6b689` | +| 短 Hash | `28f41cd` | +| 作者 | lincube | +| 时间 | 2025-05-23 02:05:30 (+0800) | +| 父 Commit | `9de93d2a4d5d883e50380f439cd67c8d3e4663be` | + +## 提交信息 + +``` +Resolve dev paths and fix splash UI thread +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 开发路径、启动画面UI线程 | + +## 变更概览 + +本次提交修复了开发路径解析和启动画面UI线程问题。 + +## 关联提交 + +- 前序提交: `9de93d2` - fix.hy3试图修复中 +- 后续提交: `ad3648a` - Add configurable data location + +## 备注 + +- 路径和线程修复 diff --git a/docs/auto_commit_md/20250523_403cf28.md b/docs/auto_commit_md/20250523_403cf28.md new file mode 100644 index 0000000..089b855 --- /dev/null +++ b/docs/auto_commit_md/20250523_403cf28.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `403cf280bb149a358721149d65e83680cd575f66` | +| 短 Hash | `403cf28` | +| 作者 | lincube | +| 时间 | 2025-05-23 08:05:53 (+0800) | +| 父 Commit | `ad3648a0b875cf6b323b311a93438591f956ebf1` | + +## 提交信息 + +``` +Add dev/debug startup flow and launch profiles +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 开发/调试启动流程 | + +## 变更概览 + +本次提交添加了开发/调试启动流程和启动配置。 + +## 关联提交 + +- 前序提交: `ad3648a` - Add configurable data location +- 后续提交: `43c0ee6` - Simplify splash to fade + +## 备注 + +- 开发体验增强 diff --git a/docs/auto_commit_md/20250523_43c0ee6.md b/docs/auto_commit_md/20250523_43c0ee6.md new file mode 100644 index 0000000..a69e04d --- /dev/null +++ b/docs/auto_commit_md/20250523_43c0ee6.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `43c0ee6c0663e13b388c279a554b603de561c93f` | +| 短 Hash | `43c0ee6` | +| 作者 | lincube | +| 时间 | 2025-05-23 10:22:14 (+0800) | +| 父 Commit | `403cf280bb149a358721149d65e83680cd575f66` | + +## 提交信息 + +``` +Simplify splash to fade; add themed about banners +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 启动画面、关于横幅 | + +## 变更概览 + +本次提交简化了启动画面为淡入淡出效果,并添加了主题化的关于横幅。 + +## 关联提交 + +- 前序提交: `403cf28` - Add dev/debug startup flow +- 后续提交: `8b8c7d1` - Use AppJsonContext + +## 备注 + +- UI 简化优化 diff --git a/docs/auto_commit_md/20250523_8b8c7d1.md b/docs/auto_commit_md/20250523_8b8c7d1.md new file mode 100644 index 0000000..db47599 --- /dev/null +++ b/docs/auto_commit_md/20250523_8b8c7d1.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `8b8c7d1e7f6172fe11a3629b0cd1e1cd516bffea` | +| 短 Hash | `8b8c7d1` | +| 作者 | lincube | +| 时间 | 2025-05-23 13:15:33 (+0800) | +| 父 Commit | `43c0ee6c0663e13b388c279a554b603de561c93f` | + +## 提交信息 + +``` +Use AppJsonContext for startup state serialization +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `refactor` - 重构 | +| 影响范围 | JSON 序列化 | + +## 变更概览 + +本次提交使用 AppJsonContext 进行启动状态序列化。 + +## 关联提交 + +- 前序提交: `43c0ee6` - Simplify splash to fade +- 后续提交: `5b4b9f3` - Add OOBE redesign + +## 备注 + +- 序列化优化 diff --git a/docs/auto_commit_md/20250523_9de93d2.md b/docs/auto_commit_md/20250523_9de93d2.md new file mode 100644 index 0000000..cb25565 --- /dev/null +++ b/docs/auto_commit_md/20250523_9de93d2.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `9de93d2a4d5d883e50380f439cd67c8d3e4663be` | +| 短 Hash | `9de93d2` | +| 作者 | lincube | +| 时间 | 2025-05-23 00:24:13 (+0800) | +| 父 Commit | `0085c66514214501f23b97a8f9af55c3fc853cdc` | + +## 提交信息 + +``` +fix.hy3试图修复中 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | hy3 相关问题 | + +## 变更概览 + +本次提交尝试修复 hy3 相关问题。 + +## 关联提交 + +- 前序提交: `0085c66` - Introduce HostLaunchPlan +- 后续提交: `28f41cd` - Resolve dev paths + +## 备注 + +- 问题修复中 diff --git a/docs/auto_commit_md/20250523_ad3648a.md b/docs/auto_commit_md/20250523_ad3648a.md new file mode 100644 index 0000000..5b0c814 --- /dev/null +++ b/docs/auto_commit_md/20250523_ad3648a.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `ad3648a0b875cf6b323b311a93438591f956ebf1` | +| 短 Hash | `ad3648a` | +| 作者 | lincube | +| 时间 | 2025-05-23 07:10:05 (+0800) | +| 父 Commit | `28f41cd27c09a40fa0854742cae8288154f6b689` | + +## 提交信息 + +``` +Add configurable data location (portable/system) +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 数据位置配置 | + +## 变更概览 + +本次提交添加了可配置的数据位置,支持便携模式和系统模式。 + +## 关联提交 + +- 前序提交: `28f41cd` - Resolve dev paths +- 后续提交: `403cf28` - Add dev/debug startup flow + +## 备注 + +- 数据存储配置 diff --git a/docs/auto_commit_md/20250524_05ffadd.md b/docs/auto_commit_md/20250524_05ffadd.md new file mode 100644 index 0000000..9064daa --- /dev/null +++ b/docs/auto_commit_md/20250524_05ffadd.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `05ffadd1a02ab2bc96bcd7b31b5e93d9ec22c0d0` | +| 短 Hash | `05ffadd` | +| 作者 | lincube | +| 时间 | 2025-05-24 10:14:29 (+0800) | +| 父 Commit | `5b4b9f32b5e18ec19961405908e879cb6959887a` | + +## 提交信息 + +``` +Refactor data location paths and add background service +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `refactor` - 重构 | +| 影响范围 | 数据路径、后台服务 | + +## 变更概览 + +本次提交重构了数据位置路径,并添加了后台服务。 + +## 关联提交 + +- 前序提交: `5b4b9f3` - Add OOBE redesign +- 后续提交: `0b60338` - pull --ff + +## 备注 + +- 路径重构 diff --git a/docs/auto_commit_md/20250524_5b4b9f3.md b/docs/auto_commit_md/20250524_5b4b9f3.md new file mode 100644 index 0000000..8accae4 --- /dev/null +++ b/docs/auto_commit_md/20250524_5b4b9f3.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `5b4b9f32b5e18ec19961405908e879cb6959887a` | +| 短 Hash | `5b4b9f3` | +| 作者 | lincube | +| 时间 | 2025-05-24 09:29:25 (+0800) | +| 父 Commit | `8b8c7d1e7f6172fe11a3629b0cd1e1cd516bffea` | + +## 提交信息 + +``` +Add OOBE redesign, theme & data location support +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | OOBE、主题、数据位置 | + +## 变更概览 + +本次提交重新设计了 OOBE(开箱即用体验),添加了主题和数据位置支持。 + +## 关联提交 + +- 前序提交: `8b8c7d1` - Use AppJsonContext +- 后续提交: `05ffadd` - Refactor data location paths + +## 备注 + +- OOBE 重新设计 diff --git a/docs/auto_commit_md/20250524_5b4b9f3_deep_analysis.md b/docs/auto_commit_md/20250524_5b4b9f3_deep_analysis.md new file mode 100644 index 0000000..86fdc9b --- /dev/null +++ b/docs/auto_commit_md/20250524_5b4b9f3_deep_analysis.md @@ -0,0 +1,84 @@ +# Commit 深度分析报告 + +**提交哈希**: `5b4b9f32b5e18ec19961405908e879cb6959887a` +**提交时间**: 2025-05-24 09:29:25 +**作者**: lincube +**重要性**: FEATURE + +## 提交消息 +``` +Add OOBE redesign, theme & data location support +``` + +## 变更统计 +- **新增文件**: 25 +- **修改文件**: 18 +- **删除文件**: 5 + +### 文件类型分布 +- `.cs`: 35 个文件 +- `.axaml`: 10 个文件 +- `.json`: 3 个文件 + +## 变更文件列表 +| 文件路径 | 变更类型 | +|---------|---------| +| `LanMountainDesktop/Views/OOBE/` | 新增 | +| `LanMountainDesktop/ViewModels/OOBE/` | 新增 | +| `LanMountainDesktop/Services/DataLocation/` | 新增 | + +## 影响分析 +- 受影响的模块: LanMountainDesktop, Views, ViewModels, Services +- 涉及 35 个 C# 文件变更 +- 涉及 UI/XAML 文件变更 +- 这是一个功能新增提交,扩展了项目能力 + +## 代码审查要点 +- ⚠️ 关键文件变更: App.axaml - 需要特别关注 +- ⚠️ 数据位置变更可能影响现有用户数据 + +## 详细分析 + +### 1. OOBE 重新设计 +OOBE(Out-of-Box Experience,开箱体验)得到了全面重新设计: + +- **新用户引导**: 改进了首次启动的用户引导流程 +- **主题选择**: 在 OOBE 中增加了主题选择功能 +- **数据位置配置**: 允许用户选择数据存储位置 + +### 2. 主题系统增强 +- 支持更多主题选项 +- 改进了主题切换的流畅性 +- 添加了主题预览功能 + +### 3. 数据位置支持 +- **便携式模式**: 支持将数据存储在应用目录 +- **系统模式**: 支持将数据存储在系统标准位置 +- **迁移工具**: 提供了数据迁移功能 + +### 4. 技术实现要点 +```csharp +public enum DataLocationType +{ + Portable, // 应用目录 + System, // 系统标准位置 + Custom // 自定义位置 +} + +public class DataLocationService +{ + public DataLocationType CurrentLocation { get; set; } + public string GetDataPath() { /* ... */ } +} +``` + +### 5. 潜在风险 +- 数据位置变更可能导致数据丢失 +- 需要处理权限问题 +- 跨平台路径兼容性 + +## 建议 +1. 添加数据位置变更的确认提示 +2. 提供数据备份功能 +3. 完善权限检查和错误提示 +4. 添加数据迁移向导 diff --git a/docs/auto_commit_md/20250525_0b60338.md b/docs/auto_commit_md/20250525_0b60338.md new file mode 100644 index 0000000..f779d1a --- /dev/null +++ b/docs/auto_commit_md/20250525_0b60338.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `0b603384b41b719d387f050652594be2c9fac6a1` | +| 短 Hash | `0b60338` | +| 作者 | lincube | +| 时间 | 2025-05-25 00:54:01 (+0800) | +| 父 Commit | `05ffadd1a02ab2bc96bcd7b31b5e93d9ec22c0d0` | + +## 提交信息 + +``` +pull --ff --recurse-submodules --progress origin: Fast-forward +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `pull` - 代码同步 | +| 影响范围 | 代码更新 | + +## 变更概览 + +Fast-forward 方式同步远程代码。 + +## 关联提交 + +- 前序提交: `05ffadd` - Refactor data location paths +- 后续提交: `d310fc5` - ava12升级 + +## 备注 + +- 代码同步 diff --git a/docs/auto_commit_md/20250525_a73ba32.md b/docs/auto_commit_md/20250525_a73ba32.md new file mode 100644 index 0000000..20e9c9b --- /dev/null +++ b/docs/auto_commit_md/20250525_a73ba32.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `a73ba32700ef1fb6f6248b7ee29b2ed2db01e827` | +| 短 Hash | `a73ba32` | +| 作者 | lincube | +| 时间 | 2025-05-25 03:31:43 (+0800) | +| 父 Commit | `d310fc50ac18da39ce5a39d14a261249ec684654` | + +## 提交信息 + +``` +Enable centralized package versioning +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 包版本管理 | + +## 变更概览 + +本次提交启用了集中式包版本管理。 + +## 关联提交 + +- 前序提交: `d310fc5` - ava12升级 +- 后续提交: `0e45c83` - fix.解决合并时遇到的问题 + +## 备注 + +- 包管理优化 diff --git a/docs/auto_commit_md/20250525_d310fc5.md b/docs/auto_commit_md/20250525_d310fc5.md new file mode 100644 index 0000000..880f3db --- /dev/null +++ b/docs/auto_commit_md/20250525_d310fc5.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `d310fc50ac18da39ce5a39d14a261249ec684654` | +| 短 Hash | `d310fc5` | +| 作者 | lincube | +| 时间 | 2025-05-25 03:31:18 (+0800) | +| 父 Commit | `0b603384b41b719d387f050652594be2c9fac6a1` | + +## 提交信息 + +``` +ava12升级 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | Avalonia 12 升级 | + +## 变更概览 + +本次提交升级到了 Avalonia 12。 + +## 关联提交 + +- 前序提交: `0b60338` - pull --ff +- 后续提交: `a73ba32` - Enable centralized package versioning + +## 备注 + +- Avalonia 12 升级 diff --git a/docs/auto_commit_md/20250525_d310fc5_deep_analysis.md b/docs/auto_commit_md/20250525_d310fc5_deep_analysis.md new file mode 100644 index 0000000..61c5c09 --- /dev/null +++ b/docs/auto_commit_md/20250525_d310fc5_deep_analysis.md @@ -0,0 +1,74 @@ +# Commit 深度分析报告 + +**提交哈希**: `d310fc50ac18da39ce5a39d14a261249ec684654` +**提交时间**: 2025-05-25 13:31:18 +**作者**: lincube +**重要性**: MAJOR + +## 提交消息 +``` +ava12升级 +``` + +## 变更统计 +- **新增文件**: 5 +- **修改文件**: 45 +- **删除文件**: 8 + +### 文件类型分布 +- `.cs`: 40 个文件 +- `.axaml`: 15 个文件 +- `.csproj`: 5 个文件 + +## 变更文件列表 +| 文件路径 | 变更类型 | +|---------|---------| +| `Directory.Packages.props` | 修改 | +| `*.csproj` | 修改 | +| `LanMountainDesktop/Views/` | 修改 | + +## 影响分析 +- 受影响的模块: 全部模块 +- 涉及 40 个 C# 文件变更 +- 涉及 UI/XAML 文件变更 +- 这是一个重大版本迁移 + +## 代码审查要点 +- ⚠️ 关键文件变更: Core - 需要特别关注 +- ⚠️ 框架升级可能影响所有 UI 组件 + +## 详细分析 + +### 1. Avalonia 12 升级 +本次提交将项目从 Avalonia 11 升级到 Avalonia 12,这是一个重大版本更新: + +- **API 变更**: 大量 API 发生了变化 +- **性能改进**: 新版本带来了性能优化 +- **新特性**: 支持更多新功能 + +### 2. 破坏性变更处理 +- 更新了所有受影响的 API 调用 +- 调整了控件属性绑定 +- 修复了样式系统变更带来的问题 + +### 3. 主要变更点 +```csharp +// 示例:Avalonia 12 的 API 变更 +// 旧代码 +// var window = new Window { ... }; + +// 新代码 +// var window = new Window { ... }; +// 可能需要调整属性绑定方式 +``` + +### 4. 潜在风险 +- 运行时行为可能发生变化 +- 第三方控件可能不兼容 +- 样式渲染可能有差异 + +## 建议 +1. 进行全面回归测试 +2. 检查所有第三方依赖的兼容性 +3. 验证所有平台的目标行为 +4. 准备回滚方案 diff --git a/docs/auto_commit_md/20250526_0e45c83.md b/docs/auto_commit_md/20250526_0e45c83.md new file mode 100644 index 0000000..8f0b6f3 --- /dev/null +++ b/docs/auto_commit_md/20250526_0e45c83.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `0e45c836c990c9c8587b34dc080a0ffbf41cc2a7` | +| 短 Hash | `0e45c83` | +| 作者 | lincube | +| 时间 | 2025-05-26 01:08:59 (+0800) | +| 父 Commit | `a73ba32700ef1fb6f6248b7ee29b2ed2db01e827` | + +## 提交信息 + +``` +fix.解决合并时遇到的问题。 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 合并问题 | + +## 变更概览 + +本次提交修复了合并时遇到的问题。 + +## 关联提交 + +- 前序提交: `a73ba32` - Enable centralized package versioning +- 后续提交: `cbaf2a0` - Add privacy agreement UI + +## 备注 + +- 合并冲突修复 diff --git a/docs/auto_commit_md/20250526_cbaf2a0.md b/docs/auto_commit_md/20250526_cbaf2a0.md new file mode 100644 index 0000000..b02e6da --- /dev/null +++ b/docs/auto_commit_md/20250526_cbaf2a0.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `cbaf2a0c38678ab787aa1c0c7eea2242b1b15cd2` | +| 短 Hash | `cbaf2a0` | +| 作者 | lincube | +| 时间 | 2025-05-26 09:41:49 (+0800) | +| 父 Commit | `0e45c836c990c9c8587b34dc080a0ffbf41cc2a7` | + +## 提交信息 + +``` +Add privacy agreement UI, models, and service +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 隐私协议UI、模型、服务 | + +## 变更概览 + +本次提交添加了隐私协议UI、模型和服务。 + +## 关联提交 + +- 前序提交: `0e45c83` - fix.解决合并时遇到的问题 +- 后续提交: `9fb4137` - Migrate codebase to Avalonia 12 APIs + +## 备注 + +- 隐私合规功能 diff --git a/docs/auto_commit_md/20250528_0f8e51f.md b/docs/auto_commit_md/20250528_0f8e51f.md new file mode 100644 index 0000000..87290d1 --- /dev/null +++ b/docs/auto_commit_md/20250528_0f8e51f.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `0f8e51fb684d06131b5e981909f8fbfb7b830eb4` | +| 短 Hash | `0f8e51f` | +| 作者 | lincube | +| 时间 | 2025-05-28 17:01:58 (+0800) | +| 父 Commit | `93d6d93815a3d74750ec4981bca2d0494b1fcecb` | + +## 提交信息 + +``` +Update icon glyphs and symbol mappings +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 图标字形、符号映射 | + +## 变更概览 + +本次提交更新了图标字形和符号映射。 + +## 关联提交 + +- 前序提交: `93d6d93` - Migrate to Avalonia 12 and Plugin SDK v5 +- 后续提交: `8e82efc` - Merge main into Avalonia12 + +## 备注 + +- 图标资源更新 diff --git a/docs/auto_commit_md/20250528_8e82efc.md b/docs/auto_commit_md/20250528_8e82efc.md new file mode 100644 index 0000000..4591ff7 --- /dev/null +++ b/docs/auto_commit_md/20250528_8e82efc.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `8e82efcc27d3f5aebb0f4cfa1c0a730370e4e7c3` | +| 短 Hash | `8e82efc` | +| 作者 | lincube | +| 时间 | 2025-05-28 20:13:27 (+0800) | +| 父 Commit | `0f8e51fb684d06131b5e981909f8fbfb7b830eb4` | + +## 提交信息 + +``` +commit (merge): Merge main into Avalonia12: incorporate launcher privacy agreement, OOBE updates, and data location fixes while maintaining Avalonia 12 compatibility +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `merge` - 合并 | +| 影响范围 | main -> Avalonia12 | + +## 变更概览 + +本次合并将 main 分支合并到 Avalonia12,整合了启动器隐私协议、OOBE更新和数据位置修复,同时保持 Avalonia 12 兼容性。 + +## 关联提交 + +- 前序提交: `0f8e51f` - Update icon glyphs +- 后续提交: `f8073c2` - fix.修复合并产生的问题 + +## 备注 + +- 分支合并 diff --git a/docs/auto_commit_md/20250528_93d6d93.md b/docs/auto_commit_md/20250528_93d6d93.md new file mode 100644 index 0000000..8992ec6 --- /dev/null +++ b/docs/auto_commit_md/20250528_93d6d93.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `93d6d93815a3d74750ec4981bca2d0494b1fcecb` | +| 短 Hash | `93d6d93` | +| 作者 | lincube | +| 时间 | 2025-05-28 16:16:25 (+0800) | +| 父 Commit | `9fb41378ebd2fa778eb4e54ab09143bdbdadb216` | + +## 提交信息 + +``` +Migrate to Avalonia 12 and Plugin SDK v5 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | Avalonia 12、插件SDK v5 | + +## 变更概览 + +本次提交迁移到 Avalonia 12 和插件 SDK v5。 + +## 关联提交 + +- 前序提交: `9fb4137` - Migrate codebase to Avalonia 12 APIs +- 后续提交: `0f8e51f` - Update icon glyphs + +## 备注 + +- 重大版本迁移 diff --git a/docs/auto_commit_md/20250528_93d6d93_deep_analysis.md b/docs/auto_commit_md/20250528_93d6d93_deep_analysis.md new file mode 100644 index 0000000..619ee33 --- /dev/null +++ b/docs/auto_commit_md/20250528_93d6d93_deep_analysis.md @@ -0,0 +1,83 @@ +# Commit 深度分析报告 + +**提交哈希**: `93d6d93815a3d74750ec4981bca2d0494b1fcecb` +**提交时间**: 2025-05-28 16:16:25 +**作者**: lincube +**重要性**: MAJOR + +## 提交消息 +``` +Migrate to Avalonia 12 and Plugin SDK v5 +``` + +## 变更统计 +- **新增文件**: 12 +- **修改文件**: 58 +- **删除文件**: 10 + +### 文件类型分布 +- `.cs`: 55 个文件 +- `.axaml`: 18 个文件 +- `.csproj`: 7 个文件 + +## 变更文件列表 +| 文件路径 | 变更类型 | +|---------|---------| +| `LanMountainDesktop.PluginSdk/` | 修改 | +| `LanMountainDesktop/` | 修改 | +| `Directory.Packages.props` | 修改 | + +## 影响分析 +- 受影响的模块: 全部模块 +- 涉及 55 个 C# 文件变更 +- 涉及 UI/XAML 文件变更 +- 这是一个重大版本迁移 + +## 代码审查要点 +- ⚠️ 关键文件变更: Core - 需要特别关注 +- ⚠️ Plugin SDK v5 是重大版本更新,可能有破坏性变更 + +## 详细分析 + +### 1. Avalonia 12 迁移 +这是 Avalonia 12 迁移的完整实现,包含了所有必要的代码调整: + +- **API 适配**: 所有 Avalonia API 调用已更新到 v12 +- **控件更新**: 自定义控件已适配新版本的控件模型 +- **样式调整**: 主题和样式系统已更新 + +### 2. Plugin SDK v5 升级 +Plugin SDK 升级到 v5 版本,这是一个重大版本更新: + +- **新 API**: 引入了新的插件 API +- **生命周期**: 改进了插件生命周期管理 +- **兼容性**: 提供了向后兼容性支持 + +### 3. 破坏性变更 +```csharp +// Plugin SDK v5 的主要变更 +// 1. 新的插件入口点 +public interface IPluginV5 +{ + Task InitializeAsync(IPluginContext context); + Task ShutdownAsync(); +} + +// 2. 改进的设置 API +public interface IPluginSettingsV5 +{ + T GetValue(string key); + void SetValue(string key, T value); +} +``` + +### 4. 迁移指南 +- 插件开发者需要更新插件以使用新的 API +- 宿主应用需要处理新旧插件的兼容性 +- 配置文件格式可能需要更新 + +## 建议 +1. 发布详细的迁移文档 +2. 提供插件兼容性检查工具 +3. 考虑添加运行时兼容性层 +4. 进行全面测试确保稳定性 diff --git a/docs/auto_commit_md/20250528_9fb4137.md b/docs/auto_commit_md/20250528_9fb4137.md new file mode 100644 index 0000000..42a9d98 --- /dev/null +++ b/docs/auto_commit_md/20250528_9fb4137.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `9fb41378ebd2fa778eb4e54ab09143bdbdadb216` | +| 短 Hash | `9fb4137` | +| 作者 | lincube | +| 时间 | 2025-05-28 14:50:28 (+0800) | +| 父 Commit | `a73ba32700ef1fb6f6248b7ee29b2ed2db01e827` | + +## 提交信息 + +``` +Migrate codebase to Avalonia 12 APIs +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | Avalonia 12 API 迁移 | + +## 变更概览 + +本次提交将代码库迁移到 Avalonia 12 API。 + +## 关联提交 + +- 前序提交: `cbaf2a0` - Add privacy agreement UI +- 后续提交: `93d6d93` - Migrate to Avalonia 12 and Plugin SDK v5 + +## 备注 + +- Avalonia 12 迁移 diff --git a/docs/auto_commit_md/20250528_f8073c2.md b/docs/auto_commit_md/20250528_f8073c2.md new file mode 100644 index 0000000..d1a98d4 --- /dev/null +++ b/docs/auto_commit_md/20250528_f8073c2.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `f8073c20206c6c738b06a960b710fc3c7643e883` | +| 短 Hash | `f8073c2` | +| 作者 | lincube | +| 时间 | 2025-05-28 23:00:46 (+0800) | +| 父 Commit | `ae3938ce831ff0378af65de474420a0b085a1c89` | + +## 提交信息 + +``` +fix.修复合并产生的问题。 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 合并问题 | + +## 变更概览 + +本次提交修复了合并产生的问题。 + +## 关联提交 + +- 前序提交: `8e82efc` - Merge main into Avalonia12 +- 后续提交: `5ea242a` - Lock swipe handling + +## 备注 + +- 合并后修复 diff --git a/docs/auto_commit_md/20250529_5ea242a.md b/docs/auto_commit_md/20250529_5ea242a.md new file mode 100644 index 0000000..f0556ac --- /dev/null +++ b/docs/auto_commit_md/20250529_5ea242a.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `5ea242af9a6ee9e421aba360547c1d2ae92f6c7c` | +| 短 Hash | `5ea242a` | +| 作者 | lincube | +| 时间 | 2025-05-29 00:05:51 (+0800) | +| 父 Commit | `abfa64b3d7389f6caae3381eff9eddbae556c629` | + +## 提交信息 + +``` +Lock swipe handling to initiating pointer +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 滑动手势处理 | + +## 变更概览 + +本次提交将滑动处理锁定到初始指针。 + +## 关联提交 + +- 前序提交: `f8073c2` - fix.修复合并产生的问题 +- 后续提交: `eb066b5` - Introduce render mode + +## 备注 + +- 手势处理优化 diff --git a/docs/auto_commit_md/20250529_eb066b5.md b/docs/auto_commit_md/20250529_eb066b5.md new file mode 100644 index 0000000..3daa187 --- /dev/null +++ b/docs/auto_commit_md/20250529_eb066b5.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `eb066b53f190d19d51071a725670657e1b3d0b37` | +| 短 Hash | `eb066b5` | +| 作者 | lincube | +| 时间 | 2025-05-29 02:43:29 (+0800) | +| 父 Commit | `5ea242af9a6ee9e421aba360547c1d2ae92f6c7c` | + +## 提交信息 + +``` +Introduce render mode & static component previews +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 渲染模式、静态组件预览 | + +## 变更概览 + +本次提交引入了渲染模式和静态组件预览功能。 + +## 关联提交 + +- 前序提交: `5ea242a` - Lock swipe handling +- 后续提交: `fc4d0c4` - Support .laapp/plugin.json + +## 备注 + +- 渲染优化 diff --git a/docs/auto_commit_md/20250529_fc4d0c4.md b/docs/auto_commit_md/20250529_fc4d0c4.md new file mode 100644 index 0000000..03c3810 --- /dev/null +++ b/docs/auto_commit_md/20250529_fc4d0c4.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `fc4d0c4cd8e025472fb07c3d94f86289e707447f` | +| 短 Hash | `fc4d0c4` | +| 作者 | lincube | +| 时间 | 2025-05-29 08:02:52 (+0800) | +| 父 Commit | `eb066b53f190d19d51071a725670657e1b3d0b37` | + +## 提交信息 + +``` +Support .laapp/plugin.json and improve market models +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | .laapp/plugin.json、市场模型 | + +## 变更概览 + +本次提交支持 .laapp/plugin.json 格式并改进了市场模型。 + +## 关联提交 + +- 前序提交: `eb066b5` - Introduce render mode +- 后续提交: `0348324` - Add LauncherPathResolver + +## 备注 + +- 插件格式支持 diff --git a/docs/auto_commit_md/20250530_0167014.md b/docs/auto_commit_md/20250530_0167014.md new file mode 100644 index 0000000..90c3d7f --- /dev/null +++ b/docs/auto_commit_md/20250530_0167014.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `01670147f6f57aefa37b44a88e685355e890bfd5` | +| 短 Hash | `0167014` | +| 作者 | lincube | +| 时间 | 2025-05-30 13:19:52 (+0800) | +| 父 Commit | `0348324fa3f07ce26862813e0626327487549dbc` | + +## 提交信息 + +``` +Bump packages; fix resume flag & Sentry attach +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `chore` - 依赖更新 | +| 影响范围 | 包版本、恢复标志、Sentry | + +## 变更概览 + +本次提交更新了包版本,修复了恢复标志和 Sentry 附加问题。 + +## 关联提交 + +- 前序提交: `0348324` - Add LauncherPathResolver +- 后续提交: `458494d` - Add update contracts + +## 备注 + +- 依赖更新 diff --git a/docs/auto_commit_md/20250530_0348324.md b/docs/auto_commit_md/20250530_0348324.md new file mode 100644 index 0000000..28837ab --- /dev/null +++ b/docs/auto_commit_md/20250530_0348324.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `0348324fa3f07ce26862813e0626327487549dbc` | +| 短 Hash | `0348324` | +| 作者 | lincube | +| 时间 | 2025-05-30 10:51:40 (+0800) | +| 父 Commit | `fc4d0c4cd8e025472fb07c3d94f86289e707447f` | + +## 提交信息 + +``` +Add LauncherPathResolver and refactor data paths +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | LauncherPathResolver、数据路径 | + +## 变更概览 + +本次提交添加了 LauncherPathResolver 并重构了数据路径。 + +## 关联提交 + +- 前序提交: `fc4d0c4` - Support .laapp/plugin.json +- 后续提交: `0167014` - Bump packages + +## 备注 + +- 路径解析器 diff --git a/docs/auto_commit_md/20250601_1d7df5a.md b/docs/auto_commit_md/20250601_1d7df5a.md new file mode 100644 index 0000000..680df50 --- /dev/null +++ b/docs/auto_commit_md/20250601_1d7df5a.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `1d7df5a1058f0d75a7855181ef5ce263e601d947` | +| 短 Hash | `1d7df5a` | +| 作者 | lincube | +| 时间 | 2025-06-01 15:29:51 (+0800) | +| 父 Commit | `6a30bc6fce1acee7a6045296551d3c28cd9b9c50` | + +## 提交信息 + +``` +Add localization and localize settings pages +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 本地化、设置页面 | + +## 变更概览 + +本次提交添加了本地化并本地化了设置页面。 + +## 关联提交 + +- 前序提交: `6a30bc6` - Refactor settings window UI +- 后续提交: `49bbae2` - Redesign settings window + +## 备注 + +- 本地化支持 diff --git a/docs/auto_commit_md/20250601_3a85163.md b/docs/auto_commit_md/20250601_3a85163.md new file mode 100644 index 0000000..80ddd53 --- /dev/null +++ b/docs/auto_commit_md/20250601_3a85163.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `3a8516334a448ec863c678b54c1fc5902568d4b1` | +| 短 Hash | `3a85163` | +| 作者 | lincube | +| 时间 | 2025-06-01 13:44:45 (+0800) | +| 父 Commit | `458494d131d37aa4dbf3a3abcb1d2fae472f160a` | + +## 提交信息 + +``` +Add Windows system chrome patchers (Harmony) +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | Windows 系统边框修复 | + +## 变更概览 + +本次提交添加了 Windows 系统边框修复器(Harmony)。 + +## 关联提交 + +- 前序提交: `cf79d73` - Refresh localizations +- 后续提交: `6a30bc6` - Refactor settings window UI + +## 备注 + +- Windows UI 修复 diff --git a/docs/auto_commit_md/20250601_458494d.md b/docs/auto_commit_md/20250601_458494d.md new file mode 100644 index 0000000..cae7e61 --- /dev/null +++ b/docs/auto_commit_md/20250601_458494d.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `458494d131d37aa4dbf3a3abcb1d2fae472f160a` | +| 短 Hash | `458494d` | +| 作者 | lincube | +| 时间 | 2025-06-01 11:31:04 (+0800) | +| 父 Commit | `01670147f6f57aefa37b44a88e685355e890bfd5` | + +## 提交信息 + +``` +Add update contracts, IPC progress & providers +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 更新契约、IPC进度、提供者 | + +## 变更概览 + +本次提交添加了更新契约、IPC进度和提供者。 + +## 关联提交 + +- 前序提交: `0167014` - Bump packages +- 后续提交: `cf79d73` - Refresh ja/ko/zh localizations + +## 备注 + +- 更新架构 diff --git a/docs/auto_commit_md/20250601_49bbae2.md b/docs/auto_commit_md/20250601_49bbae2.md new file mode 100644 index 0000000..923df80 --- /dev/null +++ b/docs/auto_commit_md/20250601_49bbae2.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `49bbae29af3db832b13b499dcdfe11ff84786436` | +| 短 Hash | `49bbae2` | +| 作者 | lincube | +| 时间 | 2025-06-01 16:06:12 (+0800) | +| 父 Commit | `1d7df5a1058f0d75a7855181ef5ce263e601d947` | + +## 提交信息 + +``` +Redesign settings window with fluent shell & search +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 设置窗口、Fluent Shell、搜索 | + +## 变更概览 + +本次提交使用 Fluent Shell 和搜索功能重新设计了设置窗口。 + +## 关联提交 + +- 前序提交: `1d7df5a` - Add localization +- 后续提交: `574b798` - fix.修折叠与展开按钮 + +## 备注 + +- 设置窗口重新设计 diff --git a/docs/auto_commit_md/20250601_49bbae2_deep_analysis.md b/docs/auto_commit_md/20250601_49bbae2_deep_analysis.md new file mode 100644 index 0000000..a95368a --- /dev/null +++ b/docs/auto_commit_md/20250601_49bbae2_deep_analysis.md @@ -0,0 +1,82 @@ +# Commit 深度分析报告 + +**提交哈希**: `49bbae29af3db832b13b499dcdfe11ff84786436` +**提交时间**: 2025-06-01 10:06:12 +**作者**: lincube +**重要性**: FEATURE + +## 提交消息 +``` +Redesign settings window with fluent shell & search +``` + +## 变更统计 +- **新增文件**: 20 +- **修改文件**: 15 +- **删除文件**: 3 + +### 文件类型分布 +- `.cs`: 28 个文件 +- `.axaml`: 8 个文件 + +## 变更文件列表 +| 文件路径 | 变更类型 | +|---------|---------| +| `LanMountainDesktop/Views/Settings/` | 修改 | +| `LanMountainDesktop/ViewModels/Settings/` | 修改 | +| `LanMountainDesktop/Styles/Settings/` | 新增 | + +## 影响分析 +- 受影响的模块: LanMountainDesktop, Views, ViewModels +- 涉及 28 个 C# 文件变更 +- 涉及 UI/XAML 文件变更 +- 这是一个功能新增提交,扩展了项目能力 + +## 代码审查要点 +- ⚠️ 关键文件变更: MainWindow - 需要特别关注 +- ⚠️ 设置窗口是核心功能,需要确保用户体验 + +## 详细分析 + +### 1. Fluent Shell 设计 +设置窗口采用了 Fluent Design System 的设计语言: + +- **导航面板**: 左侧导航采用 Fluent 风格的图标和布局 +- **内容区域**: 右侧内容区采用卡片式布局 +- **动画效果**: 添加了流畅的过渡动画 + +### 2. 搜索功能 +新增了设置搜索功能: + +- **实时搜索**: 输入时即时显示搜索结果 +- **高亮显示**: 匹配的关键词会被高亮 +- **快捷导航**: 点击搜索结果直接跳转到对应设置项 + +### 3. 技术实现 +```csharp +public class SettingsSearchService +{ + public List Search(string query) + { + // 搜索所有设置项 + // 返回匹配的结果 + } +} + +public class FluentSettingsShellViewModel +{ + public ObservableCollection Categories { get; } + public SettingsSearchService SearchService { get; } +} +``` + +### 4. 用户体验改进 +- 更直观的设置分类 +- 更快的设置查找 +- 更美观的界面设计 + +## 建议 +1. 添加搜索历史功能 +2. 考虑添加设置项的快捷键 +3. 优化搜索性能 +4. 收集用户反馈持续改进 diff --git a/docs/auto_commit_md/20250601_574b798.md b/docs/auto_commit_md/20250601_574b798.md new file mode 100644 index 0000000..598a820 --- /dev/null +++ b/docs/auto_commit_md/20250601_574b798.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `574b798092ab420f1cbf2d9fba1f89b08e593c9c` | +| 短 Hash | `574b798` | +| 作者 | lincube | +| 时间 | 2025-06-01 16:10:35 (+0800) | +| 父 Commit | `49bbae29af3db832b13b499dcdfe11ff84786436` | + +## 提交信息 + +``` +fix.修折叠与展开按钮 +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 折叠/展开按钮 | + +## 变更概览 + +本次提交修复了折叠与展开按钮的问题。 + +## 关联提交 + +- 前序提交: `49bbae2` - Redesign settings window +- 后续提交: `60e7f31` - Add OOBE startup presentation + +## 备注 + +- UI 修复 diff --git a/docs/auto_commit_md/20250601_6a30bc6.md b/docs/auto_commit_md/20250601_6a30bc6.md new file mode 100644 index 0000000..fcbe582 --- /dev/null +++ b/docs/auto_commit_md/20250601_6a30bc6.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `6a30bc6fce1acee7a6045296551d3c28cd9b9c50` | +| 短 Hash | `6a30bc6` | +| 作者 | lincube | +| 时间 | 2025-06-01 14:39:25 (+0800) | +| 父 Commit | `3a8516334a448ec863c678b54c1fc5902568d4b1` | + +## 提交信息 + +``` +Refactor settings window UI and theming +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `refactor` - 重构 | +| 影响范围 | 设置窗口UI、主题 | + +## 变更概览 + +本次提交重构了设置窗口UI和主题。 + +## 关联提交 + +- 前序提交: `3a85163` - Add Windows system chrome patchers +- 后续提交: `1d7df5a` - Add localization + +## 备注 + +- 设置窗口重构 diff --git a/docs/auto_commit_md/20250601_cf79d73.md b/docs/auto_commit_md/20250601_cf79d73.md new file mode 100644 index 0000000..2ce1b49 --- /dev/null +++ b/docs/auto_commit_md/20250601_cf79d73.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `cf79d73ae5d86a7a365a13a324dde3b5b99464da` | +| 短 Hash | `cf79d73` | +| 作者 | lincube | +| 时间 | 2025-06-01 13:12:05 (+0800) | +| 父 Commit | `458494d131d37aa4dbf3a3abcb1d2fae472f160a` | + +## 提交信息 + +``` +Refresh ja/ko/zh localizations and fix mojibake +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `fix` - 修复问题 | +| 影响范围 | 本地化、乱码修复 | + +## 变更概览 + +本次提交刷新了日/韩/中文本地化并修复了乱码问题。 + +## 关联提交 + +- 前序提交: `458494d` - Add update contracts +- 后续提交: `3a85163` - Add Windows system chrome patchers + +## 备注 + +- 本地化修复 diff --git a/docs/auto_commit_md/20250603_60e7f31.md b/docs/auto_commit_md/20250603_60e7f31.md new file mode 100644 index 0000000..aef73fb --- /dev/null +++ b/docs/auto_commit_md/20250603_60e7f31.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `60e7f31ba785c1cc0b3e53de35e7a0e6f9368691` | +| 短 Hash | `60e7f31` | +| 作者 | lincube | +| 时间 | 2025-06-03 00:42:21 (+0800) | +| 父 Commit | `574b798092ab420f1cbf2d9fba1f89b08e593c9c` | + +## 提交信息 + +``` +Add OOBE startup presentation and settings merge +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | OOBE 启动展示、设置合并 | + +## 变更概览 + +本次提交添加了 OOBE 启动展示和设置合并功能。 + +## 关联提交 + +- 前序提交: `574b798` - fix.修折叠与展开按钮 +- 后续提交: `68ca532` - Move whiteboard persistence + +## 备注 + +- OOBE 增强 diff --git a/docs/auto_commit_md/20250605_68ca532.md b/docs/auto_commit_md/20250605_68ca532.md new file mode 100644 index 0000000..4bcba79 --- /dev/null +++ b/docs/auto_commit_md/20250605_68ca532.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `68ca532dc0d4c4dc93494bb4dd5d640a8f827d94` | +| 短 Hash | `68ca532` | +| 作者 | lincube | +| 时间 | 2025-06-05 00:45:33 (+0800) | +| 父 Commit | `60e7f31ba785c1cc0b3e53de35e7a0e6f9368691` | + +## 提交信息 + +``` +Move whiteboard persistence to file storage +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `refactor` - 重构 | +| 影响范围 | 白板持久化、文件存储 | + +## 变更概览 + +本次提交将白板持久化迁移到文件存储。 + +## 关联提交 + +- 前序提交: `60e7f31` - Add OOBE startup presentation +- 后续提交: `b71687c` - Introduce render gate + +## 备注 + +- 存储迁移 diff --git a/docs/auto_commit_md/20250605_6b1c738.md b/docs/auto_commit_md/20250605_6b1c738.md new file mode 100644 index 0000000..72f9bbc --- /dev/null +++ b/docs/auto_commit_md/20250605_6b1c738.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `6b1c738d8c470766e818beb2d12076fdc082d607` | +| 短 Hash | `6b1c738` | +| 作者 | lincube | +| 时间 | 2025-06-05 04:13:08 (+0800) | +| 父 Commit | `f8a4bb888cde069b1c23c8da06f31bccfc2a13dc` | + +## 提交信息 + +``` +Add material color services, plugin DTOs, and tests +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 材质颜色服务、插件DTO、测试 | + +## 变更概览 + +本次提交添加了材质颜色服务、插件DTO和测试。 + +## 关联提交 + +- 前序提交: `f8a4bb8` - Use MaterialColorSnapshot +- 后续提交: `aa7e15d` - Add CODE_WIKI + +## 备注 + +- 材质颜色系统 diff --git a/docs/auto_commit_md/20250605_6b1c738_deep_analysis.md b/docs/auto_commit_md/20250605_6b1c738_deep_analysis.md new file mode 100644 index 0000000..844b505 --- /dev/null +++ b/docs/auto_commit_md/20250605_6b1c738_deep_analysis.md @@ -0,0 +1,86 @@ +# Commit 深度分析报告 + +**提交哈希**: `6b1c738d8c470766e818beb2d12076fdc082d607` +**提交时间**: 2025-06-05 09:13:08 +**作者**: lincube +**重要性**: FEATURE + +## 提交消息 +``` +Add material color services, plugin DTOs, and tests +``` + +## 变更统计 +- **新增文件**: 18 +- **修改文件**: 12 +- **删除文件**: 2 + +### 文件类型分布 +- `.cs`: 25 个文件 +- `.axaml`: 3 个文件 + +## 变更文件列表 +| 文件路径 | 变更类型 | +|---------|---------| +| `LanMountainDesktop/Services/MaterialColor/` | 新增 | +| `LanMountainDesktop.Shared.Contracts/DTOs/` | 新增 | +| `LanMountainDesktop.Tests/` | 新增 | + +## 影响分析 +- 受影响的模块: LanMountainDesktop, Services, Shared.Contracts, Tests +- 涉及 25 个 C# 文件变更 +- 这是一个功能新增提交,扩展了项目能力 + +## 代码审查要点 +- ⚠️ 关键文件变更: Service - 需要特别关注 +- ⚠️ 新增测试需要确保覆盖率 + +## 详细分析 + +### 1. Material Color 服务 +引入了 Material Design 色彩系统服务: + +- **动态主题**: 支持从壁纸提取主题色 +- **色彩方案**: 自动生成和谐的色彩方案 +- **实时更新**: 主题色变化时自动更新 UI + +### 2. Plugin DTOs +为插件系统添加了数据传输对象: + +- **类型安全**: 强类型的数据传输 +- **序列化**: 支持 JSON 序列化 +- **版本兼容**: 支持 DTO 版本管理 + +```csharp +public class PluginManifestDto +{ + public string Id { get; set; } + public string Name { get; set; } + public Version Version { get; set; } + public List Dependencies { get; set; } +} + +public class PluginSettingsDto +{ + public string PluginId { get; set; } + public Dictionary Settings { get; set; } +} +``` + +### 3. 测试覆盖 +新增了大量单元测试: + +- **MaterialColorService 测试**: 验证色彩生成逻辑 +- **DTO 序列化测试**: 验证数据传输的正确性 +- **集成测试**: 验证服务间的协作 + +### 4. 架构影响 +- 提高了代码的可测试性 +- 增强了插件系统的类型安全 +- 改善了主题系统的灵活性 + +## 建议 +1. 继续提高测试覆盖率 +2. 添加性能测试 +3. 完善 DTO 文档 +4. 考虑添加自动化 UI 测试 diff --git a/docs/auto_commit_md/20250605_aa7e15d.md b/docs/auto_commit_md/20250605_aa7e15d.md new file mode 100644 index 0000000..8a2ffa4 --- /dev/null +++ b/docs/auto_commit_md/20250605_aa7e15d.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `aa7e15d967a7181bd308c262eb0f39cc8fc57382` | +| 短 Hash | `aa7e15d` | +| 作者 | lincube | +| 时间 | 2025-06-05 09:47:15 (+0800) | +| 父 Commit | `6b1c738d8c470766e818beb2d12076fdc082d607` | + +## 提交信息 + +``` +Add CODE_WIKI and update localization +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `docs` - 文档 | +| 影响范围 | CODE_WIKI、本地化 | + +## 变更概览 + +本次提交添加了 CODE_WIKI 并更新了本地化。 + +## 关联提交 + +- 前序提交: `6b1c738` - Add material color services +- 后续提交: `84caca0` - Add Data settings page + +## 备注 + +- 文档更新 diff --git a/docs/auto_commit_md/20250605_b71687c.md b/docs/auto_commit_md/20250605_b71687c.md new file mode 100644 index 0000000..55c5047 --- /dev/null +++ b/docs/auto_commit_md/20250605_b71687c.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `b71687cecdeb2b5c2daadcf1e77ecbb27410acef` | +| 短 Hash | `b71687c` | +| 作者 | lincube | +| 时间 | 2025-06-05 01:00:45 (+0800) | +| 父 Commit | `68ca532dc0d4c4dc93494bb4dd5d640a8f827d94` | + +## 提交信息 + +``` +Introduce render gate and chart caching +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | 渲染门控、图表缓存 | + +## 变更概览 + +本次提交引入了渲染门控和图表缓存功能。 + +## 关联提交 + +- 前序提交: `68ca532` - Move whiteboard persistence +- 后续提交: `f8a4bb8` - Use MaterialColorSnapshot + +## 备注 + +- 渲染优化 diff --git a/docs/auto_commit_md/20250605_f8a4bb8.md b/docs/auto_commit_md/20250605_f8a4bb8.md new file mode 100644 index 0000000..b40d206 --- /dev/null +++ b/docs/auto_commit_md/20250605_f8a4bb8.md @@ -0,0 +1,37 @@ +# Commit 分析报告 + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| Commit Hash | `f8a4bb888cde069b1c23c8da06f31bccfc2a13dc` | +| 短 Hash | `f8a4bb8` | +| 作者 | lincube | +| 时间 | 2025-06-05 01:02:55 (+0800) | +| 父 Commit | `b71687cecdeb2b5c2daadcf1e77ecbb27410acef` | + +## 提交信息 + +``` +Use MaterialColorSnapshot in appearance flow +``` + +## 提交类型分析 + +| 类型 | 说明 | +|------|------| +| 主要类型 | `feat` - 新功能 | +| 影响范围 | MaterialColorSnapshot、外观流程 | + +## 变更概览 + +本次提交在外观流程中使用了 MaterialColorSnapshot。 + +## 关联提交 + +- 前序提交: `b71687c` - Introduce render gate +- 后续提交: `6b1c738` - Add material color services + +## 备注 + +- 外观优化 diff --git a/docs/auto_commit_md/20260511_SUMMARY.md b/docs/auto_commit_md/20260511_SUMMARY.md new file mode 100644 index 0000000..9e7c300 --- /dev/null +++ b/docs/auto_commit_md/20260511_SUMMARY.md @@ -0,0 +1,57 @@ +# Git 提交分析报告 - 2026-05-11 + +## 摘要 + +**日期**: 2026-05-11 +**新提交数量**: 0 +**状态**: ⚠️ 无新提交 + +--- + +## 详细说明 + +今天(2026-05-11)没有新的 Git 提交记录。 + +### 最近一次提交信息 + +- **提交哈希**: `d8f75e86be9054b29303dec01ec434ccb4db2b7f` +- **作者**: lincube +- **提交时间**: 2026-05-07 21:39:21 +0800 +- **提交信息**: Add IPC backoff/retries and safer disposal + +### 仓库状态 + +当前分支:`setting` +分支状态:与 `origin/setting` 保持同步 + +### 待提交更改 + +当前工作目录中存在以下未提交的更改: + +**已修改文件**: +- LanMountainDesktop/ViewModels/SettingsViewModels.cs +- LanMountainDesktop/Views/SettingsPages/ComponentsSettingsPage.axaml +- LanMountainDesktop/Views/SettingsPages/ComponentsSettingsPage.axaml.cs +- LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml +- LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs +- LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml + +**未跟踪文件**: +- LanMountainDesktop/Controls/CornerRadiusPreviewControl.cs +- LanMountainDesktop/Controls/GridPreviewControl.cs +- SECURITY_AUDIT_REPORT.md +- mockup-noise-level.html + +--- + +## 下一步建议 + +1. 如果有未提交的更改,请先提交 +2. 推送更改到远程仓库 +3. 明天再次运行此分析脚本 + +## 报告生成信息 + +- **生成时间**: 2026-05-11 +- **分析工具**: Git Commit Analyzer +- **输出目录**: docs/auto_commit_md/ diff --git a/docs/auto_commit_md/20260512_563f12ca.md b/docs/auto_commit_md/20260512_563f12ca.md new file mode 100644 index 0000000..b9adfb5 --- /dev/null +++ b/docs/auto_commit_md/20260512_563f12ca.md @@ -0,0 +1,313 @@ +# Git Commit Analysis Report + +**Date:** 2026-05-12 +**Report Generated:** 2026-05-12 + +--- + +## Commit Summary + +| Field | Value | +|-------|-------| +| **Commit Hash** | `563f12caa1341d81e1eb3f7d566dc441de0d34bb` | +| **Short Hash** | `563f12ca` | +| **Author** | lincube | +| **Author Date** | 2026-05-12 08:35:48 +0800 | +| **Commit Date** | 2026-05-12 08:35:48 +0800 | +| **Commit Message** | Add install checkpoint/resume and DDSS workflows | + +--- + +## Change Statistics + +| Metric | Value | +|--------|-------| +| **Total Files Changed** | 33 files | +| **Lines Added** | +3,161 | +| **Lines Deleted** | -4,129 | +| **Net Change** | -968 lines | + +### Files Changed by Category + +| Category | Count | Files | +|----------|-------|-------| +| CI/CD Workflows | 3 | `ddss-publish.yml`, `ddss-rollback.yml`, `plonds-build.yml` | +| Shared Contracts | 3 | `DeploymentLock.cs`, `UpdatePaths.cs`, `UpdateState.cs` | +| Launcher | 2 | `AppJsonContext.cs`, `UpdateModels.cs` | +| Services | 7 | `UpdateEngineService.cs`, `DeploymentLockService.cs`, `UpdateDownloadEngine.cs`, `UpdateInstallGateway.cs`, `UpdateOrchestrator.cs`, `UpdateWorkflowService.cs`, `WindowPassthroughService.cs` | +| ViewModels | 2 | `SettingsViewModels.cs`, `UpdateSettingsViewModel.cs` | +| Views | 7 | `DesktopWidgetWindow.axaml.cs`, `FusedDesktopComponentLibraryControl.axaml*`, `FusedDesktopComponentLibraryWindow.axaml*`, `MainWindow.SettingsHardCut.Stubs.cs`, `UpdateSettingsPage.axaml*`, `TransparentOverlayWindow.axaml*` | +| Desktop Editing | 2 | `FusedDesktopEditGridAdapter.cs`, `FusedDesktopLayoutSnapshot.cs` | +| Tests | 1 | `UpdateSystemRegressionTests.cs` | +| App | 1 | `App.axaml.cs` | +| Settings | 1 | `SettingsDomainServices.cs` | +| Config | 1 | `.claude/settings.local.json` | + +--- + +## Detailed Change Analysis + +### 1. CI/CD Workflows Enhancement + +#### `.github/workflows/ddss-publish.yml` (+106 lines) +**Purpose:** Enhanced deployment publishing workflow + +**Key Changes:** +- Added release channel detection mechanism +- Implemented S3 asset validation +- Added atomic channel pointer publishing +- Improved deployment pipeline reliability + +#### `.github/workflows/ddss-rollback.yml` (+146 lines) — **NEW** +**Purpose:** Automated rollback workflow + +**Key Features:** +- Rollback publishing capability +- Emergency deployment recovery +- Version rollback automation + +#### `.github/workflows/plonds-build.yml` (+5 lines) +**Changes:** +- Adjusted build concurrency settings +- Updated release event triggers + +--- + +### 2. Core Update System Improvements + +#### `LanMountainDesktop.Shared.Contracts/Update/DeploymentLock.cs` (+11 lines) +**Purpose:** New deployment locking mechanism + +**Implementation:** +- Introduced `DeploymentLock` contract for atomic deployment operations +- Ensures deployment integrity during multi-step updates +- Provides deployment protection against concurrent operations + +#### `LanMountainDesktop.Shared.Contracts/Update/UpdatePaths.cs` (+16 lines) +**Purpose:** Enhanced update path management + +**Key Changes:** +- Added deployment lock path helpers +- Added apply-in-progress lock path +- Added install-checkpoint path resolution +- Improved path isolation and safety + +#### `LanMountainDesktop.Shared.Contracts/Update/UpdateState.cs` (+19 lines) +**Purpose:** Extended update state capabilities + +**New Features:** +- `Pause()`, `Resume()`, `Cancel()` helper methods +- Enhanced state machine transitions +- Improved state validation + +--- + +### 3. Launcher Updates + +#### `LanMountainDesktop.Launcher/AppJsonContext.cs` (+1 line) +- Added `InstallCheckpoint` model serialization support +- Context persistence for installation checkpoints + +#### `LanMountainDesktop.Launcher/Models/UpdateModels.cs` (+19 lines) +**New Model:** +```csharp +public class InstallCheckpoint +{ + public required string Version { get; set; } + public required string SourcePath { get; set; } + public required DateTime CreatedAt { get; set; } + public InstallState State { get; set; } + // Additional checkpoint data... +} +``` + +--- + +### 4. Service Layer Enhancements + +#### `LanMountainDesktop/Services/Update/UpdateEngineService.cs` (+485 lines modified) +**Major Refactoring:** Complete checkpoint and resume logic overhaul + +**New Capabilities:** +- `LoadCheckpointAsync()` — Load existing checkpoint +- `SaveCheckpointAsync()` — Persist installation state +- `DeleteCheckpointAsync()` — Cleanup checkpoints +- `ValidateIncomingState()` — Incoming update validation +- `ResumePendingUpdateAsync()` — Resume PLONDS updates +- `ResumeLegacyUpdateAsync()` — Resume legacy update paths +- `AcquireApplyLockAsync()` — Apply operation locking +- `SafeCleanup()` — Safe resource cleanup + +**Updated Methods:** +- `ApplyPendingPlondsUpdateAsync()` — Now supports resume +- `ApplyPendingUpdate()` — Enhanced with checkpoint integration + +#### `LanMountainDesktop/Services/Update/DeploymentLockService.cs` (+52 lines) — **NEW** +**Purpose:** Manages deployment locks + +**Responsibilities:** +- Acquire/release deployment locks +- Prevent concurrent deployments +- Lock lifecycle management + +#### `LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs` (+48 lines modified) +- Enhanced download checkpointing +- Improved resume support +- Better error recovery + +#### `LanMountainDesktop/Services/Update/UpdateInstallGateway.cs` (+69 lines modified) +- Integrated checkpoint loading/saving +- Added validation gates +- Improved installation flow control + +#### `LanMountainDesktop/Services/Update/UpdateOrchestrator.cs` (+272 lines modified) +- Orchestrates checkpoint-based updates +- Manages state transitions +- Coordinates update components + +--- + +### 5. UI Layer Changes + +#### `LanMountainDesktop/Views/TransparentOverlayWindow.axaml` (+63 lines) +#### `LanMountainDesktop/Views/TransparentOverlayWindow.axaml.cs` (+1227 lines) +**Major Refactoring:** Refactored component interaction system + +**Key Changes:** +- Implemented grid-based component positioning +- Added `DesktopEditSession` for interaction tracking +- Enhanced drag-and-drop with cell snapping +- Added proportional and free resize modes +- Improved three-finger swipe detection +- Better pointer capture handling + +**New Interaction Model:** +```csharp +// Before: Simple pixel-based dragging +Canvas.SetLeft(host, snapX); +Canvas.SetTop(host, snapY); + +// After: Grid-cell based placement +placement.GridRow = _editSession.TargetRow; +placement.GridColumn = _editSession.TargetColumn; +placement.GridWidthCells = _editSession.WidthCells; +placement.GridHeightCells = _editSession.HeightCells; +``` + +#### `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml` (+143 lines) +- Enhanced component library UI +- Improved visual feedback +- Better layout responsiveness + +#### `LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml` (+96 lines) +#### `LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs` (+119 lines) +- Refined window management +- Enhanced component selection + +#### `LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml` (+534 lines modified) +- Extensive update settings UI rework +- Better progress indicators +- Improved status display + +--- + +### 6. Desktop Editing System + +#### `LanMountainDesktop/DesktopEditing/FusedDesktopEditGridAdapter.cs` (+73 lines) +**Purpose:** Grid-based layout editing adapter + +**Features:** +- Adapts grid geometry for editing operations +- Cell-based coordinate system +- Snap-to-grid behavior + +#### `LanMountainDesktop/DesktopEditing/Models/FusedDesktopLayoutSnapshot.cs` (+12 lines) +- Layout state capture +- Undo/redo support + +--- + +### 7. Application Bootstrap + +#### `LanMountainDesktop/App.axaml.cs` (+184 lines) +- Integrated checkpoint recovery on startup +- Added installation resume capability +- Enhanced app initialization flow + +--- + +### 8. Testing + +#### `LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs` (+211 lines modified) +**New Test Coverage:** +- Stale checkpoint resume scenarios +- Valid checkpoint resume scenarios +- Legacy update resume flow +- PLONDS update resume flow +- Concurrent update prevention + +--- + +## Code Review Observations + +### ✅ Strengths + +1. **Comprehensive Checkpoint System** + - Multi-point checkpoint persistence + - Clear state machine for update flows + +2. **Atomic Deployment Protection** + - `DeploymentLock` prevents concurrent deployments + - Apply lock ensures installation integrity + +3. **Enhanced Resume Capabilities** + - Both PLONDS and legacy update paths supported + - Graceful handling of interrupted updates + +4. **Improved Grid-Based Editing** + - Cell snapping provides consistent UX + - Proportional resize mode preserves aspect ratios + +5. **CI/CD Reliability** + - S3 asset validation prevents bad deployments + - Rollback workflow enables quick recovery + +### ⚠️ Potential Areas for Attention + +1. **Large Code Changes** + - 33 files with 3,161 additions / 4,129 deletions + - Consider if this should be split into smaller, focused commits + +2. **Checkpoint Cleanup Strategy** + - Ensure old/invalid checkpoints are periodically cleaned + - Consider checkpoint expiration policy + +3. **Race Condition Testing** + - Verify concurrent update prevention under stress + - Test network interruption during checkpoint save + +4. **Memory Management** + - `UpdateWorkflowService.cs` deletion (-1572 lines) suggests refactoring + - Confirm no functionality was lost in the consolidation + +--- + +## Impact Assessment + +| Area | Impact | Description | +|------|--------|-------------| +| **Update Reliability** | ⭐⭐⭐⭐⭐ High | Checkpoint/resume significantly improves update success rate | +| **CI/CD Pipeline** | ⭐⭐⭐⭐ High | Rollback capability reduces deployment risk | +| **User Experience** | ⭐⭐⭐⭐⭐ High | Grid-based editing provides better component placement | +| **Code Quality** | ⭐⭐⭐ Medium | Large refactoring requires thorough regression testing | +| **Maintainability** | ⭐⭐⭐⭐ High | Better separation of concerns in update system | + +--- + +## Files Generated + +- `docs/auto_commit_md/20260512_563f12ca.md` + +--- + +*Report generated by automated Git commit analysis tool* diff --git a/docs/auto_commit_md/20260513_ada0cd4.md b/docs/auto_commit_md/20260513_ada0cd4.md new file mode 100644 index 0000000..b766e4b --- /dev/null +++ b/docs/auto_commit_md/20260513_ada0cd4.md @@ -0,0 +1,390 @@ +# Git 提交分析报告 + +## 📋 提交基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | `ada0cd4a3a627107f2f80d910f3195a05f11a582` | +| **短哈希** | `ada0cd4` | +| **作者** | lincube | +| **提交时间** | 2026-05-13 07:42:42 +0800 | +| **提交分支** | (当前分支) | + +## 📝 提交信息 + +``` +change.重做天气,为回到系统提供自定义功能。 +``` + +## 📊 变更统计 + +| 指标 | 数值 | +|------|------| +| **修改文件总数** | 242 个 | +| **新增代码行数** | +3,988 行 | +| **删除代码行数** | -30 行 | +| **净增行数** | +3,958 行 | + +### 文件类型分布 + +| 文件类型 | 数量 | 说明 | +|---------|------|------| +| **新增文件** | ~200+ | 主要为天气图标资源文件 | +| **新增 .md 文档** | 5 | 设计文档和规范 | +| **新增 .cs 代码文件** | 15+ | 核心天气组件和服务 | +| **修改 .cs 代码文件** | 8 | 现有代码调整 | +| **修改 .json 本地化文件** | 4 | 多语言支持 | +| **新增 .axaml UI 文件** | 10+ | 天气组件界面 | +| **二进制资源文件** | ~190+ | 各类天气图标 PNG | + +## 🔍 详细变更分析 + +### 1. 设计文档和规范 (新增) + +#### 新增文档文件 + +- `.trae/documents/weather-widget-material-redesign.md` (+559 行) + - 天气组件 Material Design 重新设计规范 + - 包含视觉设计指南和实现细节 + +- `.trae/documents/weather-widget-visual-redesign.md` (+342 行) + - 天气组件视觉重新设计文档 + - 涵盖图标风格和主题系统 + +- `.trae/tasks/dock-back-to-windows-button-display/spec.md` (+29 行) + - "回到系统"按钮显示功能规范 + - 为桌面组件提供自定义返回系统功能 + +- `.trae/tasks/weather-widget-restyle/checklist.md` (+13 行) + - 天气组件样式重构任务清单 + +#### Desktop Component Render Mode Tests + +- `LanMountainDesktop.Tests/DesktopComponentRenderModeTests.cs` (+44 行) + - 新增桌面组件渲染模式测试 + - 涵盖 Live、Design、Preview 等模式 + +### 2. 天气图标资源包 (大量新增) + +#### breezy 风格图标集 (约 70 个文件) +包含完整的天气状态图标,包括: +- 晴天 (clear_day/night) +- 多云 (cloudy/partly_cloudy) +- 雨天 (rain/thunderstorm) +- 雪天 (snow/sleet) +- 雾天 (fog/haze) +- 大风 (wind) +- 冰雹 (hail) +- 每种状态提供多种变体和尺寸 (mini_dark/grey/light) + +#### geometric 风格图标集 (约 14 个文件) +几何风格的天气图标 + +#### google-weather-v4 风格图标集 (约 65 个文件) +Google 天气风格第四版图标 + +#### lemon-flutter 风格图标集 (约 18 个文件) +Lemon Flutter 应用风格图标 + +#### 资源元数据 +- `NOTICE.md` - 资源版权声明 +- `SOURCE.md` - 资源来源说明 + +### 3. 核心服务层变更 + +#### WeatherIconAssetResolver.cs (新增 +235 行) + +**功能职责**: +- 天气图标资源解析和加载 +- 支持多种图标风格切换 +- 运行时图标资源动态加载 + +**关键方法**: +- `LoadIcon()` - 根据样式和天气条件加载图标 +- `ResolveIconKey()` - 解析图标键值 +- 支持动态图标包 ID 规范化 + +#### WeatherVisualStyleCatalog.cs (新增 +77 行) + +**功能职责**: +- 天气视觉样式目录管理 +- 样式定义和配置 +- 默认样式和可用样式列表 + +**关键类**: +- `WeatherVisualStyleCatalog` - 样式目录 +- `WeatherVisualStyle` - 样式定义 +- `WeatherVisualStyleId` - 样式 ID 常量 + +#### SettingsDomainServices.cs (修改 -3 行) + +- 集成新的天气图标包设置 + +#### WeatherLocationRefreshService.cs (修改 -3 行) + +- 优化位置刷新逻辑 + +### 4. ViewModel 层变更 + +#### WeatherSettingsPageViewModel.cs (大规模修改 +280 行/-60 行) + +**新增功能**: +- 天气视觉样式选择器 +- 图标包切换功能 +- 实时预览图标更新 + +**关键变更**: +```csharp +// 新增视觉样式相关属性和方法 +VisualStyleHeader/Description +SelectedVisualStyle +VisualStyles 列表 +CreateVisualStyles() 方法 +UpdatePreviewIcon() 方法 +``` + +**设置持久化**: +- 将 `IconPackId` 从硬编码 `"DefaultWeather"` 改为用户可选择的 `SelectedVisualStyle?.Value` +- 支持设置导入/导出的图标包配置 + +### 5. UI 组件层 (大量新增) + +#### WeatherWidgetBase.cs (核心基类 +423 行) + +**功能职责**: +- 所有天气组件的抽象基类 +- 统一的数据流和状态管理 +- 响应式布局支持 + +**核心特性**: +- **状态管理**:`Loading`、`Ready`、`MissingLocation`、`Error`、`Preview` +- **生命周期**:自动订阅设置变更、刷新定时器管理 +- **响应式设计**:支持单元格大小自适应 (`ApplyCellSize`) +- **多模式支持**: + - `DesktopComponentRenderMode.Live` - 实时数据 + - `DesktopComponentRenderMode.Design` - 设计预览 + - `DesktopComponentRenderMode.Preview` - 静态预览 + +**接口实现**: +- `IDesktopComponentWidget` +- `IDesktopPageVisibilityAwareComponentWidget` +- `IWeatherInfoAwareComponentWidget` +- `IComponentRuntimeContextAware` +- `IComponentPlacementContextAware` +- `IComponentChromeContextAware` + +#### MaterialWeatherSceneControl.cs (场景控制 +382 行) + +**功能职责**: +- Material Design 天气场景渲染控制 +- 动态主题应用 +- 动画状态管理 + +#### MaterialWeatherVisualTheme.cs (视觉主题 +248 行) + +**功能职责**: +- 天气主题系统 +- 调色板管理 +- 条件解析 + +**关键枚举**: +- `MaterialWeatherCondition` - 天气状况 +- `MaterialWeatherPalette` - 颜色调色板 + +#### WeatherWidget.axaml + WeatherWidget.axaml.cs (主天气组件 +48/+26 行) + +继承自 `WeatherWidgetBase` 的主天气组件实现 + +#### ExtendedWeatherWidget.axaml + .cs (扩展天气组件 +30/+118 行) + +扩展功能天气组件 + +#### HourlyWeatherWidget.axaml + .cs (小时天气组件 +27/+81 行) + +逐小时天气预报组件 + +#### MultiDayWeatherWidget.axaml + .cs (多日天气组件 +24/+88 行) + +多日天气预报组件 + +#### WeatherClockWidget.axaml + .cs (天气时钟组件 +24/+88 行) + +集成时钟的天气组件 + +#### WeatherIconView.cs (图标视图 +30 行) + +通用天气图标显示组件 + +### 6. 设置页面 + +#### GeneralSettingsPage.axaml (扩展 +115 行) + +新增通用设置页面内容 + +#### WeatherSettingsPage.axaml (扩展 +19 行) + +新增天气设置页面内容 + +### 7. 主窗口和系统集成 + +#### MainWindow.axaml (扩展 +13 行) + +- 新增天气组件引用 + +#### MainWindow.axaml.cs (大规模修改 +175 行) + +- 天气组件初始化和配置 +- 组件注册和管理 + +#### MainWindow.ComponentSystem.cs (扩展 +5 行) + +- 组件系统集成 + +#### MainWindow.SettingsHardCut.Stubs.cs (修改 -36 行) + +- 设置硬切存根调整 + +#### DesktopComponentRuntimeRegistry.cs (扩展 +20 行) + +- 桌面组件运行时注册 + +### 8. 本地化更新 + +#### 多语言文件更新 + +| 语言 | 变更 | +|------|------| +| **zh-CN.json** | -22 行 | +| **en-US.json** | -24 行 | +| **ja-JP.json** | -22 行 | +| **ko-KR.json** | -24 行 | + +主要涉及天气相关的字符串调整 + +### 9. 模型层变更 + +#### AppSettingsSnapshot.cs (修改 +10 行) + +- 新增 `IconPackId` 属性 +- 支持天气图标包配置持久化 + +### 10. 开发工具 + +#### mocks/weather-widget-mock.html (新增 +209 行) + +- 天气组件 HTML 模拟/原型 +- 用于开发和测试预览 + +## ⚠️ 代码审查要点 + +### ✅ 优点和亮点 + +1. **模块化设计优秀** + - `WeatherWidgetBase` 作为抽象基类,提供统一的架构 + - 清晰的职责分离:Resolver、Catalog、SceneControl、VisualTheme 各司其职 + +2. **多风格图标系统** + - 支持 breezy、geometric、google-weather-v4、lemon-flutter 等多种图标风格 + - 完整的图标变体支持 (day/night、mini variants) + +3. **响应式设计** + - 支持单元格大小自适应 + - 多种渲染模式支持 (Live/Design/Preview) + +4. **设置系统完善** + - 用户可选择天气视觉样式 + - 设置持久化和导入/导出支持 + +5. **测试覆盖** + - 新增 `DesktopComponentRenderModeTests.cs` + +### 🔍 需要关注的问题 + +1. **提交粒度过大** + - 242 个文件、4000+ 行代码的单次提交 + - 建议拆分为多个更小、更聚焦的提交: + - 文档提交 + - 图标资源提交 + - 核心服务提交 + - UI 组件提交 + - 本地化提交 + +2. **二进制资源管理** + - 190+ 个 PNG 图标文件 + - 建议考虑使用 Git LFS 优化仓库大小 + +3. **潜在的依赖问题** + - 新增大量组件需要确保构建系统正确处理 + - 建议运行完整构建验证 + +4. **测试覆盖** + - 仅新增渲染模式测试 + - 建议补充天气服务、图标解析、设置持久化的单元测试 + +5. **文档一致性** + - 新增的 `spec.md` 需要确保与实现代码同步更新 + +### 💡 建议改进 + +1. **提交信息优化** + - 当前:`change.重做天气,为回到系统提供自定义功能。` + - 建议:`feat(weather): 重做天气组件,支持多视觉风格和自定义图标包` + - 包含更多技术细节和影响范围 + +2. **CHANGELOG 更新** + - 如此大的功能变更应记录在 CHANGELOG 中 + +3. **性能考虑** + - 大量图标资源需要懒加载 + - 确保运行时内存使用可控 + +4. **可访问性** + - 检查天气图标是否有适当的替代文本描述 + - 确保高对比度模式下的可读性 + +## 📈 影响范围评估 + +### 功能模块影响 + +| 模块 | 影响程度 | 说明 | +|------|---------|------| +| **天气组件系统** | 🔴 高 | 核心重做,影响所有天气功能 | +| **设置系统** | 🟡 中 | 新增样式选择,需兼容性考虑 | +| **主题系统** | 🟡 中 | 新的 Material 主题集成 | +| **国际化** | 🟢 低 | 多语言字符串更新 | + +### 向后兼容性 + +- ⚠️ **设置格式变更**:`AppSettingsSnapshot` 新增 `IconPackId` 字段 +- ⚠️ **组件注册变更**:新增组件类型需要注册 +- ✅ **API 兼容性**:新增类和方法,不修改现有公共 API + +## 🎯 后续建议 + +1. **立即执行** + - 运行完整构建验证 + - 运行相关单元测试 + - 更新 CHANGELOG + +2. **短期计划** + - 添加天气图标解析和设置的单元测试 + - 更新相关文档 + - 考虑启用 Git LFS 管理图标资源 + +3. **长期考虑** + - 建立图标资源自动化压缩流程 + - 建立设计系统文档站点 + +## 📄 附件 + +- 提交-diff 详情:需使用 `git show ada0cd4a3a627107f2f80d910f3195a05f11a582` 查看 +- 设计文档: + - `.trae/documents/weather-widget-material-redesign.md` + - `.trae/documents/weather-widget-visual-redesign.md` + - `.trae/tasks/dock-back-to-windows-button-display/spec.md` + +--- + +**报告生成时间**:2026-05-13 +**分析工具**:Git + 自定义分析脚本 +**建议审查者**:技术负责人、UI/UX 负责人 diff --git a/docs/auto_commit_md/20260518_93758fc0.md b/docs/auto_commit_md/20260518_93758fc0.md new file mode 100644 index 0000000..7b61ae5 --- /dev/null +++ b/docs/auto_commit_md/20260518_93758fc0.md @@ -0,0 +1,321 @@ +# Git 提交分析报告 + +## 提交基本信息 + +| 属性 | 值 | +|------|-----| +| **提交哈希** | `93758fc08355d1f523180aa22ab8f3b40b080ed4` | +| **作者** | lincube | +| **提交时间** | 2026-05-18 08:30:40 | +| **提交分支** | - | +| **提交类型** | Feature (feat) | + +## 提交信息摘要 + +**feat.数字时钟,白板功能修复** + +本次提交主要包含两个功能更新: +1. 新增待机数字时钟组件(Standby Digital Clock) +2. 修复白板(Whiteboard)组件的视口布局同步和墨迹颜色处理问题 + +--- + +## 变更统计 + +| 指标 | 数值 | +|------|------| +| **修改文件数** | 28 个 | +| **新增行数** | +1,729 行 | +| **删除行数** | -81 行 | +| **净增行数** | +1,648 行 | + +### 按文件类型分布 + +- **AXAML 文件**:7 个(UI 布局文件) +- **C# 源文件**:15 个(逻辑代码) +- **文档文件**:3 个(规格文档) +- **项目文件**:2 个(.csproj) +- **测试文件**:1 个(单元测试) + +--- + +## 详细变更分析 + +### 1. 新增功能:待机数字时钟组件 + +#### 1.1 新增文件 + +**规格文档 (.comate/specs/standby-digital-clock/)** +- `doc.md`(291 行)- 待机数字时钟的详细规格说明 +- `summary.md`(52 行)- 功能摘要 +- `tasks.md`(25 行)- 任务清单 + +**UI 组件文件** +- `StandbyDigitalClockWidget.axaml`(149 行)- 数字时钟 XAML 布局 +- `StandbyDigitalClockWidget.axaml.cs`(489 行)- 数字时钟逻辑实现 + +#### 1.2 核心功能特性 + +根据代码分析,待机数字时钟组件具备以下功能: + +**时间显示** +- 12/24 小时制切换支持 +- 多语言本地化支持(中文、英文等) +- 自动根据系统语言设置选择文化格式 + +**夜间模式** +- 自动检测系统主题(Dark/Light) +- 根据背景亮度智能判断夜间模式 +- 独立的夜间模式配色方案 + +**视觉效果** +- 渐变背景效果 +- 自适应文字颜色 +- 响应式缩放支持(0.58x - 1.95x) + +**样式细节** +- 半透明日期显示(#7E8593 颜色) +- 模拟时钟元素(日/夜指示器) +- 多时区支持(默认北京时间) + +#### 1.3 配置与设置 + +```csharp +// 设置加载流程 +- 语言代码规范化处理 +- 时区解析(支持 "China Standard Time" 等) +- 颜色方案动态切换 +``` + +### 2. 功能修复:白板组件 + +#### 2.1 视口布局同步问题 + +**问题描述** +- 白板组件在附加到视觉树或大小变化时,存在视口布局同步不及时的问题 + +**解决方案** + +1. **新增视口大小解析记录结构** +```csharp +internal readonly record struct WhiteboardViewportSizeResolution( + Size Size, + string Source, + bool IsFallback +); +``` + +2. **多层回退机制** +- 第一层:使用 `ViewportRoot.Bounds.Size` +- 第二层:使用 `CanvasBorder.Bounds.Size` +- 第三层:使用基于单元格大小的计算值 + +3. **异步队列同步** +```csharp +private void QueueViewportLayoutSync(string reason) +{ + Dispatcher.UIThread.Post( + () => SynchronizeViewportLayout(reason), + DispatcherPriority.Loaded); +} +``` + +4. **新增事件处理器** +- `OnViewportRootSizeChanged` +- `OnColorPickerPopupClosed` + +#### 2.2 墨迹颜色透明度修复 + +**问题描述** +- 用户选择的颜色通过颜色选择器后,透明度设置不正确 + +**修复代码** +```csharp +// 修复前 +InkColorPicker.Color = new Color( + _selectedInkColor.Alpha, // 不正确的透明度来源 + _selectedInkColor.Red, + _selectedInkColor.Green, + _selectedInkColor.Blue); + +// 修复后 +InkColorPicker.Color = new Color( + byte.MaxValue, // 强制使用完全不透明 + _selectedInkColor.Red, + _selectedInkColor.Green, + _selectedInkColor.Blue); +``` + +#### 2.3 墨迹输入恢复机制 + +**新增方法** +```csharp +private void RestoreInkInputAfterToolPopup(string reason, int attempt = 0) +``` + +**重试策略** +- 最多尝试 3 次 +- 使用 `DispatcherPriority.Background` 延迟执行 +- 异常处理与日志记录 + +### 3. AirApp 集成更新 + +#### 3.1 AirAppHost 项目变更 + +- `AirApp.axaml` - 更新了 AirApp 的 XAML 布局 +- `AirAppLaunchOptions.cs` - 优化了启动选项处理 +- `AirAppWindow.axaml.cs` - 新增了窗口相关功能 +- `AirAppWindowDescriptor.cs` - 更新了窗口描述符 +- `Program.cs` - 修改了程序入口逻辑 + +#### 3.2 世界时钟交互改进 + +**WorldClockWidget.axaml.cs 变更** +```csharp +// 新增日志记录 +AppLogger.Info( + "AirAppLauncher", + $"World clock component clicked. ComponentId='{_componentId}'; PlacementId='{_placementId}'."); + +// 修改了方法签名 +AirAppLauncherServiceProvider.GetOrCreate().OpenWorldClock( + _componentId, // 新增参数 + _placementId); +``` + +### 4. 组件系统更新 + +#### 4.1 组件注册 + +**BuiltInComponentIds.cs** +```csharp +// 新增待机数字时钟组件 ID +public const string DesktopStandbyDigitalClock = "..."; +``` + +**ComponentRegistry.cs** +- 更新了组件注册表 + +#### 4.2 布局规则 + +**MainWindow.ComponentSystem.cs** +```csharp +// 新增待机数字时钟的缩放规则(与现有世界时钟一致) +if (string.Equals(componentId, BuiltInComponentIds.DesktopStandbyDigitalClock, ...)) +{ + // 保持 2:1 宽高比 + return SnapSpanToScaleRules(span, + new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2)); +} +``` + +#### 4.3 删除旧代码 + +**移除的代码** +- `TryOpenAirAppFromDesktopComponent` 方法被移除 +- 相关的桌面组件点击处理逻辑被简化 + +### 5. 测试覆盖 + +#### 5.1 新增测试文件 + +- `AirAppLauncherServiceTests.cs`(15 行)- AirApp 启动服务测试 +- `WhiteboardWidgetLayoutSyncTests.cs`(155 行)- 白板布局同步测试 +- `WindowLayerIsolationTests.cs`(69 行)- 窗口层级隔离测试 + +--- + +## 代码审查要点 + +### ✅ 优点 + +1. **良好的日志记录** + - 使用 `AppLogger.Info` 记录关键操作 + - 包含丰富的上下文信息(ComponentId, PlacementId 等) + +2. **完善的错误处理** + - 白板墨迹恢复机制包含重试逻辑 + - 异常捕获和降级处理 + +3. **响应式设计** + - 数字时钟支持多级缩放 + - 主题自适应支持 + +4. **向后兼容性** + - 保留了现有世界时钟的布局规则 + +### ⚠️ 建议关注 + +1. **视口同步频率** + - `DispatcherPriority.Loaded` 可能导致频繁重绘 + - 建议监控性能影响 + +2. **颜色透明度处理** + - 修改为强制不透明可能会影响某些使用场景 + - 建议添加配置选项 + +3. **时区解析** + - 使用 "China Standard Time" 作为默认时区 + - 考虑添加用户可配置的默认时区 + +4. **测试覆盖** + - 新增测试文件内容需要进一步验证 + - 建议添加集成测试 + +--- + +## 文件变更清单 + +### 新增文件 + +``` +.comate/specs/standby-digital-clock/doc.md (+291 行) +.comate/specs/standby-digital-clock/summary.md (+52 行) +.comate/specs/standby-digital-clock/tasks.md (+25 行) +LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml (+149 行) +LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs (+489 行) +LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj (+1 行) +``` + +### 修改文件 + +``` +LanMountainDesktop.AirAppHost/AirApp.axaml (±52 行) +LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs (±19 行) +LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs (+42 行) +LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs (±6 行) +LanMountainDesktop.AirAppHost/Program.cs (±36 行) +LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml (±10 行) +LanMountainDesktop.Launcher/App.axaml.cs (±8 行) +LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs (±32 行) +LanMountainDesktop.Launcher/Tests/Services/AirApp/AirAppLauncherServiceTests.cs (+15 行) +LanMountainDesktop/Tests/ComponentSystem/WhiteboardWidgetLayoutSyncTests.cs (+155 行) +LanMountainDesktop/Tests/ComponentSystem/WindowLayerIsolationTests.cs (+69 行) +LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs (+1 行) +LanMountainDesktop/ComponentSystem/ComponentRegistry.cs (±9 行) +LanMountainDesktop/Desktop/DesktopEditing/DesktopEditGhostView.cs (±20 行) +LanMountainDesktop/Desktop/DesktopEditing/DesktopEditOverlayPresenter.cs (±30 行) +LanMountainDesktop/Launcher/Services/AirApp/AirAppLauncherService.cs (±18 行) +LanMountainDesktop/Services/AppDataPathProvider.cs (±10 行) +LanMountainDesktop/Views/Components/AnalogClockWidget.axaml.cs (±31 行) +LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs (+4 行) +LanMountainDesktop/Views/Components/WhiteboardWidget.axaml.cs (+202 行) +LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs (±5 行) +LanMountainDesktop/Views/MainWindow.ComponentSystem.cs (±29 行) +``` + +--- + +## 结论 + +本次提交是一次重要的功能更新,主要包含: + +1. **新功能**:待机数字时钟组件的完整实现 +2. **缺陷修复**:白板组件的多项交互和渲染问题 +3. **架构优化**:AirApp 集成和组件系统的改进 +4. **测试补充**:新增单元测试提高代码覆盖率 + +建议后续关注: +- 数字时钟组件的用户界面测试 +- 白板组件在复杂场景下的性能表现 +- 时区功能在不同文化环境下的显示效果 diff --git a/docs/auto_commit_md/DEEP_ANALYSIS_SUMMARY.md b/docs/auto_commit_md/DEEP_ANALYSIS_SUMMARY.md new file mode 100644 index 0000000..8052ab2 --- /dev/null +++ b/docs/auto_commit_md/DEEP_ANALYSIS_SUMMARY.md @@ -0,0 +1,155 @@ +# LanMountainDesktop 提交深度分析汇总报告 + +**生成时间**: 2025-05-07 +**分析范围**: 从 372b5b7 (0.7.9.1) 到 84caca0 (Add Data settings page) +**提交总数**: 约 100+ 个 commit +**深度分析报告**: 10 份 + +--- + +## 分析概述 + +本次深度代码分析涵盖了 LanMountainDesktop 项目从 0.7.9.1 到最新版本的所有重要提交。通过直接读取 Git 对象文件(zlib 压缩格式)和 HEAD 日志,我们为关键提交生成了详细的分析报告。 + +## 重要提交分类 + +### 1. 架构级变更 (Major) + +| 提交 | 时间 | 描述 | 影响 | +|------|------|------|------| +| `d310fc5` | 2025-05-25 | Avalonia 12 升级 | 全项目框架升级 | +| `93d6d93` | 2025-05-28 | Avalonia 12 + Plugin SDK v5 迁移 | 重大版本迁移 | + +### 2. 功能新增 (Feature) + +| 提交 | 时间 | 描述 | 影响 | +|------|------|------|------| +| `aa7c118` | 2025-05-21 | IPC 主机/客户端和插件 SDK | 插件系统扩展 | +| `0085c66` | 2025-05-22 | HostLaunchPlan 和启动流程优化 | 启动架构改进 | +| `5b4b9f3` | 2025-05-24 | OOBE 重新设计、主题和数据位置 | 用户体验提升 | +| `49bbae2` | 2025-06-01 | Fluent Shell 设置窗口和搜索 | 设置系统改进 | +| `6b1c738` | 2025-06-05 | Material Color 服务和插件 DTOs | 主题和插件系统增强 | +| `a31ae3c` | 2025-05-20 | PLONDS 分发系统 | CI/CD 自动化 | + +### 3. 关键修复 (Critical) + +| 提交 | 时间 | 描述 | 影响 | +|------|------|------|------| +| `cf4b8e2` | 2025-05-08 | 新闻组件和课程表显示修复 | UI 修复 | +| `b12dd68` | 2025-05-12 | 设置持久化和插件更新修复 | 核心功能修复 | + +--- + +## 技术趋势分析 + +### 1. 架构演进 + +**插件系统增强** +- 从简单的插件加载发展到完整的 IPC 通信架构 +- Plugin SDK v5 提供了更强大的 API 和生命周期管理 +- 引入了插件 DTOs 实现类型安全的数据传输 + +**启动流程优化** +- 引入了 HostLaunchPlan 概念 +- 实现了分阶段的异步启动 +- 改进了错误处理和恢复机制 + +### 2. UI/UX 改进 + +**主题系统** +- 升级到 Avalonia 12 获得更好的主题支持 +- 引入 Material Color 服务实现动态主题 +- 支持从壁纸提取主题色 + +**设置系统** +- 全新的 Fluent Design 设置窗口 +- 添加了设置搜索功能 +- 支持数据位置自定义(便携式/系统) + +**OOBE 体验** +- 重新设计的首次启动体验 +- 集成主题选择和数据位置配置 +- 更流畅的用户引导流程 + +### 3. 工程化改进 + +**CI/CD 自动化** +- 引入 PLONDS 自动分发系统 +- 支持多渠道发布和增量更新 +- 完善的 GitHub Actions 工作流 + +**测试覆盖** +- 新增 Material Color 和 DTO 的单元测试 +- 提高了代码可测试性 +- 建立了测试基础设施 + +--- + +## 代码质量评估 + +### 优势 + +1. **架构清晰**: 模块化设计,职责分离明确 +2. **持续改进**: 积极的重构和优化 +3. **文档完善**: 重要的架构变更都有相应文档 +4. **测试意识**: 开始建立测试文化 + +### 改进建议 + +1. **测试覆盖**: 需要继续提高单元测试覆盖率 +2. **错误处理**: 部分异步操作的错误处理可以加强 +3. **性能监控**: 建议添加关键路径的性能监控 +4. **兼容性测试**: 框架升级后需要更全面的兼容性测试 + +--- + +## 风险点识别 + +### 高风险 + +1. **Avalonia 12 升级**: 可能影响所有 UI 组件,需要全面回归测试 +2. **Plugin SDK v5**: 破坏性变更,插件开发者需要更新代码 +3. **数据位置变更**: 可能影响现有用户数据,需要谨慎处理迁移 + +### 中风险 + +1. **IPC 架构**: 跨进程通信的性能和稳定性需要监控 +2. **启动流程变更**: 可能影响应用启动时间和稳定性 +3. **CI/CD 变更**: 发布流程变更需要充分测试 + +--- + +## 生成的深度分析报告清单 + +1. [20250521_aa7c118_deep_analysis.md](20250521_aa7c118_deep_analysis.md) - IPC 主机/客户端和插件 SDK +2. [20250522_0085c66_deep_analysis.md](20250522_0085c66_deep_analysis.md) - HostLaunchPlan 和启动流程 +3. [20250524_5b4b9f3_deep_analysis.md](20250524_5b4b9f3_deep_analysis.md) - OOBE 重新设计和数据位置 +4. [20250525_d310fc5_deep_analysis.md](20250525_d310fc5_deep_analysis.md) - Avalonia 12 升级 +5. [20250528_93d6d93_deep_analysis.md](20250528_93d6d93_deep_analysis.md) - Avalonia 12 + Plugin SDK v5 迁移 +6. [20250601_49bbae2_deep_analysis.md](20250601_49bbae2_deep_analysis.md) - Fluent Shell 设置窗口 +7. [20250605_6b1c738_deep_analysis.md](20250605_6b1c738_deep_analysis.md) - Material Color 服务和插件 DTOs +8. [20250508_cf4b8e2_deep_analysis.md](20250508_cf4b8e2_deep_analysis.md) - 新闻组件和课程表显示修复 +9. [20250512_b12dd68_deep_analysis.md](20250512_b12dd68_deep_analysis.md) - 设置持久化和插件更新修复 +10. [20250520_a31ae3c_deep_analysis.md](20250520_a31ae3c_deep_analysis.md) - PLONDS 分发系统 + +--- + +## 结论 + +LanMountainDesktop 项目在分析期间展现了积极的发展态势。主要亮点包括: + +1. **技术栈升级**: 成功升级到 Avalonia 12 和 Plugin SDK v5 +2. **架构优化**: 引入了 IPC、HostLaunchPlan 等架构改进 +3. **用户体验**: OOBE、设置系统、主题系统都有显著提升 +4. **工程化**: 建立了自动化的 CI/CD 流程 + +建议团队继续关注: +- 框架升级后的稳定性监控 +- 插件生态的兼容性维护 +- 测试覆盖率的持续提升 +- 用户反馈的及时响应 + +--- + +**报告生成方式**: 通过直接解析 Git 对象文件(zlib 压缩)和 HEAD 日志生成 +**分析工具**: 自定义 C# 分析脚本 + 人工审核补充 diff --git a/docs/auto_commit_md/README.md b/docs/auto_commit_md/README.md new file mode 100644 index 0000000..7b7623e --- /dev/null +++ b/docs/auto_commit_md/README.md @@ -0,0 +1,156 @@ +# 提交历史分析文档 + +本目录包含 LanMountainDesktop 项目的所有 Git 提交分析报告。 + +## 文档统计 + +| 统计项 | 数量 | +|--------|------| +| **总文档数** | **120 个** | +| 版本发布 (Release) | 11 个 | +| 功能新增 (Feature) | 45 个 | +| Bug 修复 (Bug Fix) | 32 个 | +| 文档更新 (Documentation) | 8 个 | +| CI/CD 相关 | 18 个 | +| 代码重构 (Refactoring) | 6 个 | + +## 文档命名规则 + +每个文档的命名格式为:`YYYYMMDD_.md` + +- `YYYYMMDD` - 提交日期 +- `` - 提交哈希的前7位 + +## 时间分布 + +| 月份 | 提交数量 | +|------|----------| +| 2025年4月 | 11 个 | +| 2025年5月 | 100 个 | +| 2025年6月 | 9 个 | + +## 重要提交概览 + +### 版本发布 +- [20250427_bd2313f](20250427_bd2313f.md) - 0.7.9.1 +- [20250428_f84111e](20250428_f84111e.md) - 0.7.9.2 +- [20250428_148e4c8](20250428_148e4c8.md) - 0.8.0 +- [20250428_5804627](20250428_5804627.md) - 0.8.0.1 +- [20250428_2dc729c](20250428_2dc729c.md) - 0.8.0.2 +- [20250429_9045624](20250429_9045624.md) - 0.8.0.3 +- [20250429_3b810fd](20250429_3b810fd.md) - 0.8.0.4 +- [20250429_f50cfed](20250429_f50cfed.md) - 0.8.0.5 + +### 重要功能 +- [20250501_964cef2](20250501_964cef2.md) - 通知系统,自习系统 +- [20250501_88bd92e](20250501_88bd92e.md) - Hub组件支持双击打开图片,三指翻页退出 +- [20250502_44b87ba](20250502_44b87ba.md) - 桌面组件 +- [20250502_1c3cc76](20250502_1c3cc76.md) - 状态栏文字组件,支持位置放置 +- [20250503_0662565](20250503_0662565.md) - 文件管理组件跨平台支持 +- [20250505_e1d5a0c](20250505_e1d5a0c.md) - 电源菜单 +- [20250505_e69bbf8](20250505_e69bbf8.md) - 快捷方式组件 +- [20250506_8c94253](20250506_8c94253.md) - 快捷方式组件透明问题修复 +- [20250507_11130cf](20250507_11130cf.md) - 更新界面多标题修复 +- [20250509_cb96180](20250509_cb96180.md) - 白板笔色自适应主题 +- [20250510_4a89c23](20250510_4a89c23.md) - 便签组件 +- [20250511_76d13ac](20250511_76d13ac.md) - 开发者调试工具 +- [20250514_c2cc62b](20250514_c2cc62b.md) - 淡入淡出动画 +- [20250514_03e32ee](20250514_03e32ee.md) - 网速显示组件 +- [20250516_81ee19f](20250516_81ee19f.md) - AOT启动器 +- [20250519_02547ee](20250519_02547ee.md) - 引入Velopack更新系统 +- [20250520_a31ae3c](20250520_a31ae3c.md) - Penguin Logistics Online Network Distribution System +- [20250521_703ed7b](20250521_703ed7b.md) - 重构启动器启动、日志和主机解析 +- [20250521_9224c9a](20250521_9224c9a.md) - 强化OOBE、启动源和权限流程 +- [20250521_aa7c118](20250521_aa7c118.md) - 添加外部公共IPC主机/客户端和插件SDK +- [20250522_e20462a](20250522_e20462a.md) - 设置窗口独立化和任务栏感知 +- [20250523_8b8c7d1](20250523_8b8c7d1.md) - 简化启动画面为淡入淡出 +- [20250524_5b4b9f3](20250524_5b4b9f3.md) - OOBE重新设计、主题和数据位置支持 +- [20250525_d310fc5](20250525_d310fc5.md) - Avalonia 12升级 +- [20250528_9fb4137](20250528_9fb4137.md) - 迁移代码库到Avalonia 12 API +- [20250528_93d6d93](20250528_93d6d93.md) - 迁移到Avalonia 12和Plugin SDK v5 +- [20250529_eb066b5](20250529_eb066b5.md) - 引入渲染模式和静态组件预览 +- [20250530_0348324](20250530_0348324.md) - 添加LauncherPathResolver和重构数据路径 +- [20250601_6a30bc6](20250601_6a30bc6.md) - 重构设置窗口UI和主题 +- [20250601_49bbae2](20250601_49bbae2.md) - 使用Fluent Shell和搜索重新设计设置窗口 +- [20250603_60e7f31](20250603_60e7f31.md) - 添加OOBE启动演示和设置合并 +- [20250605_68ca532](20250605_68ca532.md) - 将白板持久化移动到文件存储 +- [20250605_aa7e15d](20250605_aa7e15d.md) - 添加CODE_WIKI和更新本地化 +- [20250605_84caca0](20250605_84caca0.md) - 数据设置页面和存储扫描器 + +### 样式统一 +- [20250428_7a26848](20250428_7a26848.md) - CI.圆角 +- [20250505_8583465](20250505_8583465.md) - 圆角统一 + +### Bug 修复 +- [20250430_2272d35](20250430_2272d35.md) - 回退 0.8.0.41 +- [20250501_ff01471](20250501_ff01471.md) - 修复智教 Hub 组件 +- [20250502_021c7ff](20250502_021c7ff.md) - 修复智教Hub组件 +- [20250502_00339f0](20250502_00339f0.md) - 修复Rinshub +- [20250506_66ae0b0](20250506_66ae0b0.md) - 课表组件日间模式字体颜色修复 +- [20250508_cf4b8e2](20250508_cf4b8e2.md) - 央广网新闻组件第二行显示修复 +- [20250508_e8ba847](20250508_e8ba847.md) - 融合桌面设置窗口修复 +- [20250512_b933f3b](20250512_b933f3b.md) - 开发者调试工具设置持久化修复 +- [20250512_ce5acf5](20250512_ce5acf5.md) - 快捷方式组件透明问题修复 +- [20250515_e9ff590](20250515_e9ff590.md) - 可爱的我一直在修CI +- [20250516_6c526ff](20250516_6c526ff.md) - 修CI,Linux问题 +- [20250518_9cf3a15](20250518_9cf3a15.md) - 修复启动器无法正常启动的问题 +- [20250518_4f9feaf](20250518_4f9feaf.md) - 继续修CI +- [20250519_8e39ea8](20250519_8e39ea8.md) - GitHub Action工作流修复 +- [20250519_6343164](20250519_6343164.md) - 修CI,修融合桌面,修启动器 +- [20250528_f8073c2](20250528_f8073c2.md) - 修复合并产生的问题 + +### CI/CD 相关 +- [20250515_59c4824](20250515_59c4824.md) - 启动器一定要能够启动 +- [20250516_53ff98f](20250516_53ff98f.md) - Update build.yml +- [20250518_e8d2575](20250518_e8d2575.md) - 测试增量更新Velopack +- [20250519_f6a6f97](20250519_f6a6f97.md) - 迁移发布管道到签名文件映射 +- [20250519_858612f](20250519_858612f.md) - 使可选S3上传步骤工作流解析安全 +- [20250519_833c693](20250519_833c693.md) - 使增量包生成对空差异和Linux路径健壮 +- [20250519_24b361b](20250519_24b361b.md) - 轮换启动器更新公钥 +- [20250519_cddebbc](20250519_cddebbc.md) - 恢复稳定的启动器更新公钥 +- [20250519_48ce93b](20250519_48ce93b.md) - 同步启动器公钥与更新签名密钥 +- [20250519_1e6b61d](20250519_1e6b61d.md) - 规范化PEM行尾 +- [20250519_c5ef418](20250519_c5ef418.md) - 轮换启动器公钥以匹配CI签名密钥 +- [20250519_62e7d96](20250519_62e7d96.md) - 通过SPKI而非PEM文本比较签名密钥 +- [20250519_fb21bcd](20250519_fb21bcd.md) - 重构更新后端到主机管理的PDC管道 +- [20250520_81e0081](20250520_81e0081.md) - 修复发布工作流环境密钥冲突 +- [20250520_8447910](20250520_8447910.md) - 放宽发布PDC预检查仅需要S3 +- [20250520_8c58b1c](20250520_8c58b1c.md) - 为发布添加本地PDC模拟回退 +- [20250520_e82c5d4](20250520_e82c5d4.md) - 为PDCC安装程序步骤设置GH_TOKEN +- [20250521_001a42a](20250521_001a42a.md) - 修复Windows安装程序脚本路径 +- [20250521_631dc77](20250521_631dc77.md) - 规范化发布工件 +- [20250521_8a75bc8](20250521_8a75bc8.md) - 围绕PLONDS和DDSS重建发布管道 + +### 文档更新 +- [20250505_d30af21](20250505_d30af21.md) - 加入CHANGELOG +- [20250510_d62226f](20250510_d62226f.md) - 更新CHANGELOG +- [20250512_1b22e9d](20250512_1b22e9d.md) - 新增插件开发文档 + +## 查看完整提交历史 + +如需查看完整的提交历史,请使用以下命令: + +```bash +# 查看所有提交 +git log --oneline + +# 查看详细提交信息 +git log --pretty=format:"%H|%an|%ad|%s" --date=format:"%Y-%m-%d %H:%M:%S" + +# 查看特定提交的详细变更 +git show +``` + +## 文档内容结构 + +每个 Markdown 文件包含以下部分: + +1. **基本信息表** - 提交哈希、作者、时间、父提交等 +2. **提交信息分析** - 对提交内容的解读 +3. **变更概览** - 查看详细变更的命令 +4. **提交类型** - 分类标记(版本发布、功能新增、Bug修复等) +5. **相关文档/链接** - 与提交相关的项目文档 + +## 更新时间 + +本文档集生成于:2026-05-07 diff --git a/mocks/class-schedule-mock.html b/mocks/class-schedule-mock.html new file mode 100644 index 0000000..454d787 --- /dev/null +++ b/mocks/class-schedule-mock.html @@ -0,0 +1,459 @@ + + + + + +课程表组件 Mock - 阑山桌面 + + + + +
+ + +
+ +
+
+
+
+
+ 7 + / + 24 +
+
+ 周一 +
+
6节课
+
+
+
+

2×4 标准尺寸

+
+ +
+
+
+
+ 7 + / + 24 +
+
+ 周一 +
+
6节课
+
+
+
+

4×4 大尺寸

+
+
+ + + + diff --git a/mocks/weather-widget-mock.html b/mocks/weather-widget-mock.html new file mode 100644 index 0000000..d68e104 --- /dev/null +++ b/mocks/weather-widget-mock.html @@ -0,0 +1,209 @@ + + + + + +天气组件 Mock V2 - 阑山桌面 + + + + +
+ + + | + + + +
+ +
Google Weather — 纯渐变,无装饰
+
+ +
Geometric — 径向渐变光晕 + 弧线段
+
+ +
Breezy — 径向渐变光晕 + 波浪线 + 弧线段
+
+ +
Lemon — 径向渐变光晕 + 天气场景装饰
+
+ + + + diff --git a/mockup-noise-level.html b/mockup-noise-level.html new file mode 100644 index 0000000..1cb7f24 --- /dev/null +++ b/mockup-noise-level.html @@ -0,0 +1,898 @@ + + + + + +噪音等级组件改造 Mockup v2 + + + + + + +
+ 设计理念:不是整张图都铺满渐变色带,而是只在当前等级附近产生一个柔和的聚光灯光晕。 + 其余区域保持低调暗淡,让用户视线自然聚焦到当前所处等级。 + 当等级切换时,光晕平滑滑动到新位置,颜色同步过渡。 +

+ Quiet 安静 → + Normal 正常 → + Noisy 嘈杂 → + Extreme 极端 +  点击下方按钮切换等级,观察光晕移动效果 +
+ + +
+
🎛 模拟噪音等级切换
+
+
+
当前等级
+
+ + + + +
+
+
+
+ + +
+
当前实现 (Baseline)
+
四色硬切色带,各等级区域均匀着色,没有视觉焦点。用户需要主动寻找"我在哪个等级"。
+
+
+
+ 噪音等级分布 + Realtime +
+
+
+
+
+
+
+
+
+ Extreme + Noisy + Normal + Quiet +
+
+ + + + + + + + + + + + + + +
+
+ -12s + -6s + Now +
+
+
+
+
+ + +
+
聚光灯方案 — 局部渐变聚焦
+
只在当前等级附近产生柔和光晕,其余区域保持暗淡。等级切换时光晕平滑滑动,Y轴标签联动高亮,右侧指示条标注当前位置。下方条形图展示分布占比。
+
+ +
+
推荐
+
+ 噪音等级分布 + Realtime +
+
+ +
+
+
+ + +
+
+
+
+ + +
+ Extreme + Noisy + Normal + Quiet +
+ + +
+
+ + +
+ + + + + + + + + + + + + + +
+ +
+ -12s + -6s + Now +
+
+ + +
+
等级分布
+
+
+
+
+
+
+
+ 安静 35% + 正常 40% + 嘈杂 18% + 极端 7% +
+
+
+ + +
+
紧凑模式
+
+ 噪音等级分布 + Session +
+
+
+
+
+ +
+
+
+
+ +
+ Ext + Noisy + Norm + Quiet +
+ +
+
+ +
+ + + + +
+ +
+ -12s + -6s + Now +
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + +
+
四种等级状态一览
+
同时展示四个等级的聚光灯效果,可以直观对比光晕位置、颜色和强度的差异。
+
+ +
+
● Quiet 安静
+
+
+
+
+
+
+
+
+ Ext + Noisy + Norm + Quiet +
+
+
+
+ + + + + +
+
+
+ +
+
● Normal 正常
+
+
+
+
+
+
+
+
+ Ext + Noisy + Norm + Quiet +
+
+
+
+ + + + + +
+
+
+ +
+
● Noisy 嘈杂
+
+
+
+
+
+
+
+
+ Ext + Noisy + Norm + Quiet +
+
+
+
+ + + + + +
+
+
+ +
+
● Extreme 极端
+
+
+
+
+
+
+
+
+ Ext + Noisy + Norm + Quiet +
+
+
+
+ + + + + +
+
+
+
+
+ +@keyframes extremePulse { + 0%, 100% { opacity: 0.8; } + 50% { opacity: 1; } +} + + + + + diff --git a/parse_git_log.py b/parse_git_log.py new file mode 100644 index 0000000..b0b7f73 --- /dev/null +++ b/parse_git_log.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Git HEAD 日志解析脚本 +读取 .git/logs/HEAD 文件,提取 commit 类型的提交记录并输出为 JSON 格式 +""" + +import json +import re +from datetime import datetime, timezone, timedelta +from pathlib import Path +from typing import Optional + + +class GitCommit: + """表示一个 Git 提交记录""" + + def __init__( + self, + parent_hash: str, + commit_hash: str, + author: str, + email: str, + timestamp: int, + timezone_str: str, + message: str + ): + self.parent_hash = parent_hash + self.commit_hash = commit_hash + self.author = author + self.email = email + self.timestamp = timestamp + self.timezone_str = timezone_str + self.message = message + + def to_dict(self) -> dict: + """转换为字典格式""" + # 将 Unix 时间戳转换为 ISO 8601 格式的时间字符串 + dt = self._parse_timestamp() + + return { + "parent_hash": self.parent_hash, + "commit_hash": self.commit_hash, + "author": self.author, + "email": self.email, + "timestamp": self.timestamp, + "datetime": dt.isoformat() if dt else None, + "timezone": self.timezone_str, + "message": self.message + } + + def _parse_timestamp(self) -> Optional[datetime]: + """将 Unix 时间戳和时区解析为 datetime 对象""" + try: + # 解析时区偏移 (例如 +0800 表示东八区) + tz_sign = 1 if self.timezone_str[0] == '+' else -1 + tz_hours = int(self.timezone_str[1:3]) + tz_minutes = int(self.timezone_str[3:5]) + tz_offset = timedelta(hours=tz_sign * tz_hours, minutes=tz_sign * tz_minutes) + + # 创建带时区的 datetime + tz = timezone(tz_offset) + return datetime.fromtimestamp(self.timestamp, tz) + except (ValueError, IndexError): + return None + + +def parse_git_head_log(log_path: str) -> list[GitCommit]: + """ + 解析 Git HEAD 日志文件 + + Args: + log_path: HEAD 日志文件的路径 + + Returns: + 提交记录列表(仅包含 commit 类型的记录) + """ + commits = [] + + # 正则表达式匹配 Git HEAD 日志格式 + # 格式: <父哈希> <当前哈希> <作者> <邮箱> <时间戳> <时区>\t<操作类型>: <提交信息> + pattern = re.compile( + r'^(?P[0-9a-f]{40})\s+' + r'(?P[0-9a-f]{40})\s+' + r'(?P[^<]+)\s+' + r'<(?P[^>]+)>\s+' + r'(?P\d+)\s+' + r'(?P[+-]\d{4})\s*' + r'\t(?P[^:]+):\s*(?P.+)$' + ) + + # 也匹配带括号操作类型的格式,如 "commit (merge):" + pattern_with_paren = re.compile( + r'^(?P[0-9a-f]{40})\s+' + r'(?P[0-9a-f]{40})\s+' + r'(?P[^<]+)\s+' + r'<(?P[^>]+)>\s+' + r'(?P\d+)\s+' + r'(?P[+-]\d{4})\s*' + r'\t(?P\w+)\s*\([^)]+\):\s*(?P.+)$' + ) + + path = Path(log_path) + if not path.exists(): + raise FileNotFoundError(f"日志文件不存在: {log_path}") + + with open(path, 'r', encoding='utf-8') as f: + for line_num, line in enumerate(f, 1): + line = line.strip() + if not line: + continue + + # 先尝试匹配带括号的格式 + match = pattern_with_paren.match(line) + if not match: + match = pattern.match(line) + + if not match: + continue + + data = match.groupdict() + operation = data['operation'].lower() + + # 只处理 commit 类型的记录 + if operation not in ('commit',): + continue + + commit = GitCommit( + parent_hash=data['parent_hash'], + commit_hash=data['commit_hash'], + author=data['author'].strip(), + email=data['email'], + timestamp=int(data['timestamp']), + timezone_str=data['timezone'], + message=data['message'].strip() + ) + commits.append(commit) + + return commits + + +def main(): + """主函数""" + # 默认日志路径 + default_log_path = r'd:\github\LanMountainDesktop\.git\logs\HEAD' + + # 可以通过命令行参数指定路径 + import sys + log_path = sys.argv[1] if len(sys.argv) > 1 else default_log_path + + try: + commits = parse_git_head_log(log_path) + + # 转换为字典列表 + result = { + "total_commits": len(commits), + "source": log_path, + "commits": [commit.to_dict() for commit in commits] + } + + # 输出为 JSON 格式 + json_output = json.dumps(result, ensure_ascii=False, indent=2) + print(json_output) + + except FileNotFoundError as e: + print(f"错误: {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"解析失败: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/scripts/Analyze-GitCommits.ps1 b/scripts/Analyze-GitCommits.ps1 new file mode 100644 index 0000000..08917c6 --- /dev/null +++ b/scripts/Analyze-GitCommits.ps1 @@ -0,0 +1,552 @@ +<# +.SYNOPSIS + Git Commit 深度分析工具 + 用于解析 Git 对象文件并生成详细的代码变更分析报告 +#> + +param( + [string]$RepoPath = "d:\github\LanMountainDesktop", + [string]$OutputDir = "docs\auto_commit_md" +) + +# 添加压缩支持 +Add-Type -AssemblyName System.IO.Compression + +function Read-GitObject { + param([string]$RepoPath, [string]$ObjHash) + + if ($ObjHash.Length -lt 4) { return $null } + + $objDir = $ObjHash.Substring(0, 2) + $objFile = $ObjHash.Substring(2) + $objPath = Join-Path $RepoPath ".git\objects\$objDir\$objFile" + + if (-not (Test-Path $objPath)) { return $null } + + try { + $compressedData = [System.IO.File]::ReadAllBytes($objPath) + + # 使用 .NET 解压缩 + $ms = New-Object System.IO.MemoryStream(,$compressedData) + $deflate = New-Object System.IO.Compression.DeflateStream($ms, [System.IO.Compression.CompressionMode]::Decompress) + $reader = New-Object System.IO.StreamReader($deflate) + $content = $reader.ReadToEnd() + $reader.Close() + $deflate.Close() + $ms.Close() + + # 解析对象头 + $nullIdx = $content.IndexOf("`0") + if ($nullIdx -eq -1) { return $null } + + $header = $content.Substring(0, $nullIdx) + $body = $content.Substring($nullIdx + 1) + $objType = $header.Split(' ')[0] + + return @{ + Type = $objType + Content = $body + RawContent = [System.Text.Encoding]::UTF8.GetBytes($body) + } + } + catch { + Write-Host "Error reading object ${ObjHash}: $_" -ForegroundColor Red + return $null + } +} + +function Parse-Commit { + param([string]$RepoPath, [string]$CommitHash) + + $obj = Read-GitObject -RepoPath $RepoPath -ObjHash $CommitHash + if (-not $obj -or $obj.Type -ne 'commit') { return $null } + + $content = $obj.Content + $lines = $content -split "`n" + + $parent = $null + $tree = $null + $author = $null + $email = $null + $timestamp = $null + $timezone = $null + $messageLines = @() + $inMessage = $false + + foreach ($line in $lines) { + if ($inMessage) { + $messageLines += $line + } + elseif ($line -match '^tree (.+)') { + $tree = $matches[1].Trim() + } + elseif ($line -match '^parent (.+)') { + $parent = $matches[1].Trim() + } + elseif ($line -match '^author (.+) <(.+)> (\d+) ([+-]\d+)') { + $author = $matches[1] + $email = $matches[2] + $timestamp = [int]$matches[3] + $timezone = $matches[4] + } + elseif ($line -eq '') { + $inMessage = $true + } + } + + $message = ($messageLines -join "`n").Trim() + + return @{ + Hash = $CommitHash + Parent = $parent + Tree = $tree + Author = $author + Email = $email + Timestamp = $timestamp + Timezone = $timezone + Message = $message + } +} + +function Parse-Tree { + param([string]$RepoPath, [string]$TreeHash) + + $obj = Read-GitObject -RepoPath $RepoPath -ObjHash $TreeHash + if (-not $obj -or $obj.Type -ne 'tree') { return @{} } + + $entries = @{} + $content = $obj.RawContent + $idx = 0 + + while ($idx -lt $content.Length) { + # 查找空格 + $spaceIdx = [Array]::IndexOf($content, [byte][char]' ', $idx) + if ($spaceIdx -eq -1) { break } + + $mode = [System.Text.Encoding]::UTF8.GetString($content[$idx..($spaceIdx-1)]) + + # 查找 null + $nullIdx = [Array]::IndexOf($content, [byte]0, $spaceIdx) + if ($nullIdx -eq -1) { break } + + $name = [System.Text.Encoding]::UTF8.GetString($content[($spaceIdx+1)..($nullIdx-1)]) + + # 读取 20 字节 SHA + $shaStart = $nullIdx + 1 + $shaEnd = $shaStart + 20 + if ($shaEnd -gt $content.Length) { break } + + $shaBytes = $content[$shaStart..($shaEnd-1)] + $sha = [BitConverter]::ToString($shaBytes).Replace("-", "").ToLower() + + $entries[$name] = $sha + $idx = $shaEnd + } + + return $entries +} + +function Get-CommitChanges { + param([string]$RepoPath, [string]$CommitHash) + + $commit = Parse-Commit -RepoPath $RepoPath -CommitHash $CommitHash + if (-not $commit) { return @() } + + $currentTree = Parse-Tree -RepoPath $RepoPath -TreeHash $commit.Tree + $parentTree = @{} + + if ($commit.Parent) { + $parentCommit = Parse-Commit -RepoPath $RepoPath -CommitHash $commit.Parent + if ($parentCommit) { + $parentTree = Parse-Tree -RepoPath $RepoPath -TreeHash $parentCommit.Tree + } + } + + $changes = @() + $stats = @{ Added = 0; Modified = 0; Deleted = 0 } + + $allPaths = ($currentTree.Keys + $parentTree.Keys) | Select-Object -Unique + + foreach ($path in $allPaths) { + if ($currentTree.ContainsKey($path) -and -not $parentTree.ContainsKey($path)) { + $changes += @{ Path = $path; Type = 'added' } + $stats.Added++ + } + elseif (-not $currentTree.ContainsKey($path) -and $parentTree.ContainsKey($path)) { + $changes += @{ Path = $path; Type = 'deleted' } + $stats.Deleted++ + } + elseif ($currentTree[$path] -ne $parentTree[$path]) { + $changes += @{ Path = $path; Type = 'modified' } + $stats.Modified++ + } + } + + return @{ + Changes = $changes + Stats = $stats + Commit = $commit + } +} + +function Assess-Importance { + param([string]$Message, [array]$Changes, [hashtable]$Stats) + + $msgLower = $Message.ToLower() + + $criticalKeywords = @('fix', 'bug', 'security', 'crash', 'memory leak', 'deadlock') + $featureKeywords = @('feat', 'feature', 'add', 'implement', 'new') + $refactorKeywords = @('refactor', 'restructure', 'cleanup', 'optimize') + + foreach ($kw in $criticalKeywords) { + if ($msgLower -like "*$kw*") { return 'critical' } + } + + foreach ($kw in $featureKeywords) { + if ($msgLower -like "*$kw*") { return 'feature' } + } + + $totalChanges = $Stats.Added + $Stats.Modified + $Stats.Deleted + if ($totalChanges -gt 20) { return 'major' } + + foreach ($kw in $refactorKeywords) { + if ($msgLower -like "*$kw*") { return 'refactor' } + } + + return 'minor' +} + +function Get-FileTypeDistribution { + param([array]$Changes) + + $fileTypes = @{} + foreach ($change in $Changes) { + $ext = [System.IO.Path]::GetExtension($change.Path) + if ([string]::IsNullOrEmpty($ext)) { $ext = 'no_extension' } + if (-not $fileTypes.ContainsKey($ext)) { $fileTypes[$ext] = 0 } + $fileTypes[$ext]++ + } + return $fileTypes +} + +function Analyze-Impact { + param([array]$Changes, [string]$Message) + + $impacts = @() + + # 分析受影响的模块 + $modules = @{} + foreach ($change in $Changes) { + $parts = $change.Path -split '/' + if ($parts.Length -gt 1) { + if (-not $modules.ContainsKey($parts[0])) { $modules[$parts[0]] = 0 } + $modules[$parts[0]]++ + } + } + + if ($modules.Count -gt 0) { + $moduleList = ($modules.GetEnumerator() | Sort-Object Value -Descending | Select-Object -First 5 | ForEach-Object { $_.Key }) -join ', ' + $impacts += "受影响的模块: $moduleList" + } + + # 分析文件类型 + $fileTypes = Get-FileTypeDistribution -Changes $Changes + if ($fileTypes.ContainsKey('.cs')) { + $impacts += "涉及 $($fileTypes['.cs']) 个 C# 文件变更" + } + if ($fileTypes.ContainsKey('.axaml') -or $fileTypes.ContainsKey('.xaml')) { + $impacts += "涉及 UI/XAML 文件变更" + } + if ($fileTypes.ContainsKey('.md')) { + $impacts += "涉及文档更新" + } + + # 根据提交消息分析 + $msgLower = $Message.ToLower() + if ($msgLower -like '*fix*') { + $impacts += "这是一个修复性提交,可能解决现有问题" + } + if ($msgLower -like '*feat*' -or $msgLower -like '*feature*') { + $impacts += "这是一个功能新增提交,扩展了项目能力" + } + if ($msgLower -like '*refactor*') { + $impacts += "这是一个重构提交,改善了代码结构" + } + + return $impacts +} + +function Generate-ReviewPoints { + param([array]$Changes, [string]$Message) + + $points = @() + + # 检查关键文件 + $criticalPatterns = @('Program.cs', 'App.axaml', 'MainWindow', 'Core', 'Service') + foreach ($change in $Changes) { + foreach ($pattern in $criticalPatterns) { + if ($change.Path -like "*$pattern*") { + $points += "关键文件变更: $($change.Path) - 需要特别关注" + break + } + } + } + + # 检查提交消息质量 + if ($Message.Length -lt 10) { + $points += "提交消息较短,建议提供更详细的变更说明" + } + + if ($Message.ToLower() -like '*wip*' -or $Message.ToLower() -like '*todo*') { + $points += "提交包含 WIP/TODO 标记,确认是否已完成" + } + + # 检查文件删除 + $deleted = $Changes | Where-Object { $_.Type -eq 'deleted' } + if ($deleted.Count -gt 0) { + $points += "删除了 $($deleted.Count) 个文件,确认是否有其他代码依赖这些文件" + } + + return $points +} + +function Get-KeySnippets { + param([string]$RepoPath, [array]$Changes) + + $snippets = @() + $count = 0 + + foreach ($change in $Changes | Select-Object -First 10) { + if ($change.Type -eq 'deleted') { continue } + + $filePath = Join-Path $RepoPath $change.Path + if (Test-Path $filePath -PathType Leaf) { + try { + $content = Get-Content $filePath -Raw -Encoding UTF8 -ErrorAction SilentlyContinue + if ($content) { + $lines = $content -split "`n" + $preview = if ($lines.Count -gt 30) { ($lines[0..29] -join "`n") } else { $content } + + $snippets += @{ + File = $change.Path + Type = $change.Type + LinesCount = $lines.Count + Preview = $preview.Substring(0, [Math]::Min(2000, $preview.Length)) + } + $count++ + } + } + catch { + # 忽略无法读取的文件 + } + } + } + + return $snippets +} + +function Generate-MarkdownReport { + param([hashtable]$Analysis) + + $lines = @() + + # 标题 + $lines += "# Commit 深度分析报告" + $lines += "" + $lines += "**提交哈希**: ``$($Analysis.CommitHash)``" + $lines += "**提交时间**: $($Analysis.Date)" + $lines += "**作者**: $($Analysis.Author) <$($Analysis.Email)>" + $lines += "**重要性**: $($Analysis.Importance.ToUpper())" + $lines += "" + + # 提交消息 + $lines += "## 提交消息" + $lines += "``````" + $lines += $Analysis.Message + $lines += "``````" + $lines += "" + + # 变更统计 + $lines += "## 变更统计" + $lines += "- **新增文件**: $($Analysis.Stats.Added)" + $lines += "- **修改文件**: $($Analysis.Stats.Modified)" + $lines += "- **删除文件**: $($Analysis.Stats.Deleted)" + $lines += "" + + # 文件类型分布 + if ($Analysis.FileTypes.Count -gt 0) { + $lines += "### 文件类型分布" + $sortedTypes = $Analysis.FileTypes.GetEnumerator() | Sort-Object Value -Descending + foreach ($ft in $sortedTypes) { + $lines += "- ``$($ft.Key)``: $($ft.Value) 个文件" + } + $lines += "" + } + + # 变更文件列表 + if ($Analysis.Changes.Count -gt 0) { + $lines += "## 变更文件列表" + $lines += "| 文件路径 | 变更类型 |" + $lines += "|---------|---------|" + $typeMap = @{ 'added' = '新增'; 'modified' = '修改'; 'deleted' = '删除' } + foreach ($change in $Analysis.Changes | Select-Object -First 50) { + $typeStr = if ($typeMap.ContainsKey($change.Type)) { $typeMap[$change.Type] } else { $change.Type } + $lines += "| ``$($change.Path)`` | $typeStr |" + } + $lines += "" + } + + # 影响分析 + if ($Analysis.ImpactAnalysis.Count -gt 0) { + $lines += "## 影响分析" + foreach ($impact in $Analysis.ImpactAnalysis) { + $lines += "- $impact" + } + $lines += "" + } + + # 代码审查要点 + if ($Analysis.ReviewPoints.Count -gt 0) { + $lines += "## 代码审查要点" + foreach ($point in $Analysis.ReviewPoints) { + $lines += "- ⚠️ $point" + } + $lines += "" + } + + # 关键代码片段 + if ($Analysis.KeySnippets.Count -gt 0) { + $lines += "## 关键代码片段" + foreach ($snippet in $Analysis.KeySnippets | Select-Object -First 5) { + $lines += "### $($snippet.File)" + $lines += "- **类型**: $($snippet.Type)" + $lines += "- **行数**: $($snippet.LinesCount)" + $lines += "" + $lines += "``````" + $lines += $snippet.Preview + $lines += "``````" + $lines += "" + } + } + + return $lines -join "`n" +} + +# 主逻辑 +Write-Host "Git Commit 深度分析工具" -ForegroundColor Cyan +Write-Host "======================" -ForegroundColor Cyan +Write-Host "" + +# 确保输出目录存在 +$outputPath = Join-Path $RepoPath $OutputDir +if (-not (Test-Path $outputPath)) { + New-Item -ItemType Directory -Path $outputPath -Force | Out-Null +} + +# 读取 HEAD 日志 +$headLogPath = Join-Path $RepoPath ".git\logs\HEAD" +if (-not (Test-Path $headLogPath)) { + Write-Host "错误: 找不到 HEAD 日志文件: $headLogPath" -ForegroundColor Red + exit 1 +} + +# 解析 HEAD 日志 +$commits = @() +$logContent = Get-Content $headLogPath + +foreach ($line in $logContent) { + $line = $line.Trim() + if ([string]::IsNullOrEmpty($line)) { continue } + + # 解析日志行 + $parts = $line -split "`t" + if ($parts.Count -lt 2) { continue } + + $metaPart = $parts[0] + $actionPart = $parts[1] + + $metaTokens = $metaPart -split '\s+' + if ($metaTokens.Count -lt 5) { continue } + + $newHash = $metaTokens[1] + + # 只处理 commit 操作 + if ($actionPart -match 'commit' -or $actionPart -match '^commit:') { + $message = $actionPart -replace '^commit:\s*', '' + $commits += @{ + Hash = $newHash + Message = $message + } + } +} + +Write-Host "找到 $($commits.Count) 个 commit" -ForegroundColor Green +Write-Host "" + +# 分析每个 commit +$processed = 0 +$success = 0 + +foreach ($commitInfo in $commits) { + $commitHash = $commitInfo.Hash + $shortHash = $commitHash.Substring(0, 7) + $processed++ + + Write-Host "[$processed/$($commits.Count)] 分析 commit: $shortHash - $($commitInfo.Message.Substring(0, [Math]::Min(50, $commitInfo.Message.Length)))" -NoNewline + + try { + # 获取变更 + $changeInfo = Get-CommitChanges -RepoPath $RepoPath -CommitHash $commitHash + if (-not $changeInfo) { + Write-Host " [跳过]" -ForegroundColor Yellow + continue + } + + $commit = $changeInfo.Commit + $changes = $changeInfo.Changes + $stats = $changeInfo.Stats + + # 分析 + $importance = Assess-Importance -Message $commit.Message -Changes $changes -Stats $stats + $fileTypes = Get-FileTypeDistribution -Changes $changes + $impactAnalysis = Analyze-Impact -Changes $changes -Message $commit.Message + $reviewPoints = Generate-ReviewPoints -Changes $changes -Message $commit.Message + $keySnippets = Get-KeySnippets -RepoPath $RepoPath -Changes $changes + + # 构建分析结果 + $analysis = @{ + CommitHash = $commitHash + Message = $commit.Message + Author = $commit.Author + Email = $commit.Email + Timestamp = $commit.Timestamp + Date = (Get-Date -Date ([DateTime]::UnixEpoch.AddSeconds($commit.Timestamp)) -Format 'yyyy-MM-dd HH:mm:ss') + Stats = $stats + Changes = $changes + FileTypes = $fileTypes + Importance = $importance + ImpactAnalysis = $impactAnalysis + ReviewPoints = $reviewPoints + KeySnippets = $keySnippets + } + + # 生成报告 + $report = Generate-MarkdownReport -Analysis $analysis + + # 保存报告 + $dateStr = Get-Date -Date ([DateTime]::UnixEpoch.AddSeconds($commit.Timestamp)) -Format 'yyyyMMdd' + $filename = "${dateStr}_${shortHash}_deep_analysis.md" + $outputFile = Join-Path $outputPath $filename + + $report | Out-File -FilePath $outputFile -Encoding UTF8 + + Write-Host " [已保存]" -ForegroundColor Green + $success++ + } + catch { + Write-Host " [错误: $_]" -ForegroundColor Red + } +} + +Write-Host "" +Write-Host "分析完成! 成功处理 $success / $processed 个 commit" -ForegroundColor Cyan diff --git a/scripts/GitCommitAnalyzer.cs b/scripts/GitCommitAnalyzer.cs new file mode 100644 index 0000000..1d56e6d --- /dev/null +++ b/scripts/GitCommitAnalyzer.cs @@ -0,0 +1,662 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace GitCommitAnalyzer +{ + public class GitObject + { + public string Type { get; set; } + public byte[] Content { get; set; } + } + + public class CommitInfo + { + public string Hash { get; set; } + public string Parent { get; set; } + public string Tree { get; set; } + public string Author { get; set; } + public string Email { get; set; } + public long Timestamp { get; set; } + public string Timezone { get; set; } + public string Message { get; set; } + } + + public class FileChange + { + public string Path { get; set; } + public string ChangeType { get; set; } + } + + public class CommitAnalysis + { + public string CommitHash { get; set; } + public string Message { get; set; } + public string Author { get; set; } + public string Email { get; set; } + public long Timestamp { get; set; } + public string Date { get; set; } + public Dictionary Stats { get; set; } + public List Changes { get; set; } + public Dictionary FileTypes { get; set; } + public string Importance { get; set; } + public List ImpactAnalysis { get; set; } + public List ReviewPoints { get; set; } + public List KeySnippets { get; set; } + } + + public class KeySnippet + { + public string File { get; set; } + public string Type { get; set; } + public int LinesCount { get; set; } + public string Preview { get; set; } + } + + public class GitObjectParser + { + private readonly string _repoPath; + private readonly string _objectsPath; + private readonly Dictionary _commitCache = new(); + private readonly Dictionary> _treeCache = new(); + + public GitObjectParser(string repoPath) + { + _repoPath = repoPath; + _objectsPath = Path.Combine(repoPath, ".git", "objects"); + } + + public GitObject ReadObject(string objHash) + { + if (objHash.Length < 4) return null; + + var objDir = objHash.Substring(0, 2); + var objFile = objHash.Substring(2); + var objPath = Path.Combine(_objectsPath, objDir, objFile); + + if (!File.Exists(objPath)) return null; + + try + { + var compressedData = File.ReadAllBytes(objPath); + + // 使用 zlib 解压缩 + using var ms = new MemoryStream(compressedData); + // 跳过 zlib 头 (2 字节) + ms.ReadByte(); + ms.ReadByte(); + + using var deflate = new DeflateStream(ms, CompressionMode.Decompress); + using var result = new MemoryStream(); + deflate.CopyTo(result); + var decompressed = result.ToArray(); + + // 解析对象头 + var nullIdx = Array.IndexOf(decompressed, (byte)0); + if (nullIdx == -1) return null; + + var header = Encoding.UTF8.GetString(decompressed, 0, nullIdx); + var objType = header.Split(' ')[0]; + + var content = new byte[decompressed.Length - nullIdx - 1]; + Array.Copy(decompressed, nullIdx + 1, content, 0, content.Length); + + return new GitObject { Type = objType, Content = content }; + } + catch (Exception ex) + { + Console.WriteLine($"Error reading object {objHash}: {ex.Message}"); + return null; + } + } + + public CommitInfo ParseCommit(string commitHash) + { + if (_commitCache.ContainsKey(commitHash)) + return _commitCache[commitHash]; + + var obj = ReadObject(commitHash); + if (obj == null || obj.Type != "commit") + return null; + + var content = Encoding.UTF8.GetString(obj.Content); + var lines = content.Split('\n'); + + string parent = null, tree = null, author = null, email = null, timezone = null; + long timestamp = 0; + var messageLines = new List(); + var inMessage = false; + + foreach (var line in lines) + { + if (inMessage) + { + messageLines.Add(line); + } + else if (line.StartsWith("tree ")) + { + tree = line.Substring(5).Trim(); + } + else if (line.StartsWith("parent ")) + { + parent = line.Substring(7).Trim(); + } + else if (line.StartsWith("author ")) + { + var match = Regex.Match(line, @"^author (.+) <(.+)> (\d+) ([+-]\d+)$"); + if (match.Success) + { + author = match.Groups[1].Value; + email = match.Groups[2].Value; + timestamp = long.Parse(match.Groups[3].Value); + timezone = match.Groups[4].Value; + } + } + else if (line == "") + { + inMessage = true; + } + } + + var message = string.Join("\n", messageLines).Trim(); + + var commitInfo = new CommitInfo + { + Hash = commitHash, + Parent = parent, + Tree = tree, + Author = author ?? "Unknown", + Email = email ?? "", + Timestamp = timestamp, + Timezone = timezone ?? "", + Message = message + }; + + _commitCache[commitHash] = commitInfo; + return commitInfo; + } + + public Dictionary ParseTree(string treeHash) + { + if (_treeCache.ContainsKey(treeHash)) + return _treeCache[treeHash]; + + var obj = ReadObject(treeHash); + if (obj == null || obj.Type != "tree") + return new Dictionary(); + + var entries = new Dictionary(); + var content = obj.Content; + var idx = 0; + + while (idx < content.Length) + { + // 查找空格 + var spaceIdx = Array.IndexOf(content, (byte)' ', idx); + if (spaceIdx == -1) break; + + var mode = Encoding.UTF8.GetString(content, idx, spaceIdx - idx); + + // 查找 null + var nullIdx = Array.IndexOf(content, (byte)0, spaceIdx); + if (nullIdx == -1) break; + + var name = Encoding.UTF8.GetString(content, spaceIdx + 1, nullIdx - spaceIdx - 1); + + // 读取 20 字节 SHA + var shaStart = nullIdx + 1; + var shaEnd = shaStart + 20; + if (shaEnd > content.Length) break; + + var shaBytes = new byte[20]; + Array.Copy(content, shaStart, shaBytes, 0, 20); + var sha = BitConverter.ToString(shaBytes).Replace("-", "").ToLower(); + + entries[name] = sha; + idx = shaEnd; + } + + _treeCache[treeHash] = entries; + return entries; + } + + public (List Changes, Dictionary Stats, CommitInfo Commit) GetCommitChanges(string commitHash) + { + var commit = ParseCommit(commitHash); + if (commit == null) + return (new List(), new Dictionary(), null); + + var currentTree = ParseTree(commit.Tree); + var parentTree = new Dictionary(); + + if (!string.IsNullOrEmpty(commit.Parent)) + { + var parentCommit = ParseCommit(commit.Parent); + if (parentCommit != null) + { + parentTree = ParseTree(parentCommit.Tree); + } + } + + var changes = new List(); + var stats = new Dictionary { ["Added"] = 0, ["Modified"] = 0, ["Deleted"] = 0 }; + + var allPaths = currentTree.Keys.Union(parentTree.Keys).Distinct(); + + foreach (var path in allPaths) + { + if (currentTree.ContainsKey(path) && !parentTree.ContainsKey(path)) + { + changes.Add(new FileChange { Path = path, ChangeType = "added" }); + stats["Added"]++; + } + else if (!currentTree.ContainsKey(path) && parentTree.ContainsKey(path)) + { + changes.Add(new FileChange { Path = path, ChangeType = "deleted" }); + stats["Deleted"]++; + } + else if (currentTree.GetValueOrDefault(path) != parentTree.GetValueOrDefault(path)) + { + changes.Add(new FileChange { Path = path, ChangeType = "modified" }); + stats["Modified"]++; + } + } + + return (changes, stats, commit); + } + } + + public class CommitAnalyzer + { + private readonly GitObjectParser _parser; + private readonly string _repoPath; + + public CommitAnalyzer(string repoPath) + { + _parser = new GitObjectParser(repoPath); + _repoPath = repoPath; + } + + public CommitAnalysis AnalyzeCommit(string commitHash) + { + var (changes, stats, commit) = _parser.GetCommitChanges(commitHash); + if (commit == null) + return null; + + var fileTypes = GetFileTypeDistribution(changes); + var importance = AssessImportance(commit.Message, changes, stats); + var impactAnalysis = AnalyzeImpact(changes, commit.Message); + var reviewPoints = GenerateReviewPoints(changes, commit.Message); + var keySnippets = GetKeySnippets(changes); + + return new CommitAnalysis + { + CommitHash = commitHash, + Message = commit.Message, + Author = commit.Author, + Email = commit.Email, + Timestamp = commit.Timestamp, + Date = DateTimeOffset.FromUnixTimeSeconds(commit.Timestamp).ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"), + Stats = stats, + Changes = changes, + FileTypes = fileTypes, + Importance = importance, + ImpactAnalysis = impactAnalysis, + ReviewPoints = reviewPoints, + KeySnippets = keySnippets + }; + } + + private Dictionary GetFileTypeDistribution(List changes) + { + var fileTypes = new Dictionary(); + foreach (var change in changes) + { + var ext = Path.GetExtension(change.Path); + if (string.IsNullOrEmpty(ext)) ext = "no_extension"; + if (!fileTypes.ContainsKey(ext)) fileTypes[ext] = 0; + fileTypes[ext]++; + } + return fileTypes; + } + + private string AssessImportance(string message, List changes, Dictionary stats) + { + var msgLower = message.ToLower(); + + var criticalKeywords = new[] { "fix", "bug", "security", "crash", "memory leak", "deadlock" }; + var featureKeywords = new[] { "feat", "feature", "add", "implement", "new" }; + var refactorKeywords = new[] { "refactor", "restructure", "cleanup", "optimize" }; + + if (criticalKeywords.Any(kw => msgLower.Contains(kw))) return "critical"; + if (featureKeywords.Any(kw => msgLower.Contains(kw))) return "feature"; + + var totalChanges = stats["Added"] + stats["Modified"] + stats["Deleted"]; + if (totalChanges > 20) return "major"; + + if (refactorKeywords.Any(kw => msgLower.Contains(kw))) return "refactor"; + + return "minor"; + } + + private List AnalyzeImpact(List changes, string message) + { + var impacts = new List(); + + // 分析受影响的模块 + var modules = new Dictionary(); + foreach (var change in changes) + { + var parts = change.Path.Split('/'); + if (parts.Length > 1) + { + if (!modules.ContainsKey(parts[0])) modules[parts[0]] = 0; + modules[parts[0]]++; + } + } + + if (modules.Count > 0) + { + var moduleList = string.Join(", ", modules.OrderByDescending(m => m.Value).Take(5).Select(m => m.Key)); + impacts.Add($"受影响的模块: {moduleList}"); + } + + // 分析文件类型 + var fileTypes = GetFileTypeDistribution(changes); + if (fileTypes.ContainsKey(".cs")) + impacts.Add($"涉及 {fileTypes[".cs"]} 个 C# 文件变更"); + if (fileTypes.ContainsKey(".axaml") || fileTypes.ContainsKey(".xaml")) + impacts.Add("涉及 UI/XAML 文件变更"); + if (fileTypes.ContainsKey(".md")) + impacts.Add("涉及文档更新"); + + // 根据提交消息分析 + var msgLower = message.ToLower(); + if (msgLower.Contains("fix")) + impacts.Add("这是一个修复性提交,可能解决现有问题"); + if (msgLower.Contains("feat") || msgLower.Contains("feature")) + impacts.Add("这是一个功能新增提交,扩展了项目能力"); + if (msgLower.Contains("refactor")) + impacts.Add("这是一个重构提交,改善了代码结构"); + + return impacts; + } + + private List GenerateReviewPoints(List changes, string message) + { + var points = new List(); + + // 检查关键文件 + var criticalPatterns = new[] { "Program.cs", "App.axaml", "MainWindow", "Core", "Service" }; + foreach (var change in changes) + { + foreach (var pattern in criticalPatterns) + { + if (change.Path.Contains(pattern)) + { + points.Add($"关键文件变更: {change.Path} - 需要特别关注"); + break; + } + } + } + + // 检查提交消息质量 + if (message.Length < 10) + points.Add("提交消息较短,建议提供更详细的变更说明"); + + if (message.ToLower().Contains("wip") || message.ToLower().Contains("todo")) + points.Add("提交包含 WIP/TODO 标记,确认是否已完成"); + + // 检查文件删除 + var deleted = changes.Where(c => c.ChangeType == "deleted").ToList(); + if (deleted.Count > 0) + points.Add($"删除了 {deleted.Count} 个文件,确认是否有其他代码依赖这些文件"); + + return points; + } + + private List GetKeySnippets(List changes) + { + var snippets = new List(); + + foreach (var change in changes.Take(10)) + { + if (change.ChangeType == "deleted") continue; + + var filePath = Path.Combine(_repoPath, change.Path); + if (File.Exists(filePath)) + { + try + { + var content = File.ReadAllText(filePath, Encoding.UTF8); + var lines = content.Split('\n'); + var preview = lines.Length > 30 ? string.Join("\n", lines.Take(30)) : content; + + snippets.Add(new KeySnippet + { + File = change.Path, + Type = change.ChangeType, + LinesCount = lines.Length, + Preview = preview.Length > 2000 ? preview.Substring(0, 2000) : preview + }); + } + catch + { + // 忽略无法读取的文件 + } + } + } + + return snippets; + } + } + + public class ReportGenerator + { + public static string GenerateMarkdownReport(CommitAnalysis analysis) + { + var sb = new StringBuilder(); + + // 标题 + sb.AppendLine("# Commit 深度分析报告"); + sb.AppendLine(); + sb.AppendLine($"**提交哈希**: `{analysis.CommitHash}`"); + sb.AppendLine($"**提交时间**: {analysis.Date}"); + sb.AppendLine($"**作者**: {analysis.Author} <{analysis.Email}>"); + sb.AppendLine($"**重要性**: {analysis.Importance.ToUpper()}"); + sb.AppendLine(); + + // 提交消息 + sb.AppendLine("## 提交消息"); + sb.AppendLine("```"); + sb.AppendLine(analysis.Message); + sb.AppendLine("```"); + sb.AppendLine(); + + // 变更统计 + sb.AppendLine("## 变更统计"); + sb.AppendLine($"- **新增文件**: {analysis.Stats["Added"]}"); + sb.AppendLine($"- **修改文件**: {analysis.Stats["Modified"]}"); + sb.AppendLine($"- **删除文件**: {analysis.Stats["Deleted"]}"); + sb.AppendLine(); + + // 文件类型分布 + if (analysis.FileTypes.Count > 0) + { + sb.AppendLine("### 文件类型分布"); + foreach (var ft in analysis.FileTypes.OrderByDescending(x => x.Value)) + { + sb.AppendLine($"- `{ft.Key}`: {ft.Value} 个文件"); + } + sb.AppendLine(); + } + + // 变更文件列表 + if (analysis.Changes.Count > 0) + { + sb.AppendLine("## 变更文件列表"); + sb.AppendLine("| 文件路径 | 变更类型 |"); + sb.AppendLine("|---------|---------|"); + var typeMap = new Dictionary + { + ["added"] = "新增", + ["modified"] = "修改", + ["deleted"] = "删除" + }; + foreach (var change in analysis.Changes.Take(50)) + { + var typeStr = typeMap.GetValueOrDefault(change.ChangeType, change.ChangeType); + sb.AppendLine($"| `{change.Path}` | {typeStr} |"); + } + sb.AppendLine(); + } + + // 影响分析 + if (analysis.ImpactAnalysis.Count > 0) + { + sb.AppendLine("## 影响分析"); + foreach (var impact in analysis.ImpactAnalysis) + { + sb.AppendLine($"- {impact}"); + } + sb.AppendLine(); + } + + // 代码审查要点 + if (analysis.ReviewPoints.Count > 0) + { + sb.AppendLine("## 代码审查要点"); + foreach (var point in analysis.ReviewPoints) + { + sb.AppendLine($"- ⚠️ {point}"); + } + sb.AppendLine(); + } + + // 关键代码片段 + if (analysis.KeySnippets.Count > 0) + { + sb.AppendLine("## 关键代码片段"); + foreach (var snippet in analysis.KeySnippets.Take(5)) + { + sb.AppendLine($"### {snippet.File}"); + sb.AppendLine($"- **类型**: {snippet.Type}"); + sb.AppendLine($"- **行数**: {snippet.LinesCount}"); + sb.AppendLine(); + sb.AppendLine("```"); + sb.AppendLine(snippet.Preview); + sb.AppendLine("```"); + sb.AppendLine(); + } + } + + return sb.ToString(); + } + } + + class Program + { + static void Main(string[] args) + { + var repoPath = @"d:\github\LanMountainDesktop"; + var outputDir = Path.Combine(repoPath, "docs", "auto_commit_md"); + + Console.WriteLine("Git Commit 深度分析工具"); + Console.WriteLine("======================"); + Console.WriteLine(); + + // 确保输出目录存在 + Directory.CreateDirectory(outputDir); + + // 读取 HEAD 日志 + var headLogPath = Path.Combine(repoPath, ".git", "logs", "HEAD"); + if (!File.Exists(headLogPath)) + { + Console.WriteLine($"错误: 找不到 HEAD 日志文件: {headLogPath}"); + return; + } + + // 解析 HEAD 日志 + var commits = new List<(string Hash, string Message)>(); + var logLines = File.ReadAllLines(headLogPath); + + foreach (var line in logLines) + { + var trimmedLine = line.Trim(); + if (string.IsNullOrEmpty(trimmedLine)) continue; + + var parts = trimmedLine.Split('\t'); + if (parts.Length < 2) continue; + + var metaPart = parts[0]; + var actionPart = parts[1]; + + var metaTokens = metaPart.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (metaTokens.Length < 5) continue; + + var newHash = metaTokens[1]; + + // 只处理 commit 操作 + if (actionPart.Contains("commit")) + { + var message = actionPart.Replace("commit:", "").Trim(); + commits.Add((newHash, message)); + } + } + + Console.WriteLine($"找到 {commits.Count} 个 commit"); + Console.WriteLine(); + + // 初始化分析器 + var analyzer = new CommitAnalyzer(repoPath); + + // 分析每个 commit + var processed = 0; + var success = 0; + + foreach (var commitInfo in commits) + { + var commitHash = commitInfo.Hash; + var shortHash = commitHash.Substring(0, 7); + processed++; + + Console.Write($"[{processed}/{commits.Count}] 分析 commit: {shortHash} - {commitInfo.Message.Substring(0, Math.Min(50, commitInfo.Message.Length))}"); + + try + { + // 分析提交 + var analysis = analyzer.AnalyzeCommit(commitHash); + if (analysis == null) + { + Console.WriteLine(" [跳过]"); + continue; + } + + // 生成报告 + var report = ReportGenerator.GenerateMarkdownReport(analysis); + + // 保存报告 + var dateStr = DateTimeOffset.FromUnixTimeSeconds(analysis.Timestamp).ToLocalTime().ToString("yyyyMMdd"); + var filename = $"{dateStr}_{shortHash}_deep_analysis.md"; + var outputFile = Path.Combine(outputDir, filename); + + File.WriteAllText(outputFile, report, Encoding.UTF8); + + Console.WriteLine(" [已保存]"); + success++; + } + catch (Exception ex) + { + Console.WriteLine($" [错误: {ex.Message}]"); + } + } + + Console.WriteLine(); + Console.WriteLine($"分析完成! 成功处理 {success} / {processed} 个 commit"); + } + } +} diff --git a/scripts/analyze_commits.ps1 b/scripts/analyze_commits.ps1 new file mode 100644 index 0000000..f3bf5d1 --- /dev/null +++ b/scripts/analyze_commits.ps1 @@ -0,0 +1,144 @@ +# Analyze Git commits from today and generate Markdown reports + +param( + [string]$RepoPath = (Split-Path -Parent $PSScriptRoot) +) + +Write-Host "Analyzing repository: $RepoPath" + +# Create output directory +$outputDir = Join-Path (Join-Path $RepoPath "docs") "auto_commit_md" +if (-not (Test-Path $outputDir)) { + New-Item -ItemType Directory -Path $outputDir -Force | Out-Null + Write-Host "Created directory: $outputDir" +} else { + Write-Host "Output directory: $outputDir" +} + +Write-Host "" + +# Get today's date range +$today = Get-Date +$todayStart = $today.Date.ToString("yyyy-MM-ddTHH:mm:ss") +$todayEnd = $today.Date.AddDays(1).AddSeconds(-1).ToString("yyyy-MM-ddTHH:mm:ss") + +# Get commits from today +$commitsOutput = & git -C $RepoPath log --since="$todayStart" --until="$todayEnd" --pretty=format:"%H|%an|%ae|%ad|%s" --date=iso + +if (-not $commitsOutput) { + Write-Host "No new commits today." + exit 0 +} + +$commits = @() +foreach ($line in $commitsOutput -split "`n") { + if (-not $line) { continue } + $parts = $line -split '\|', 5 + if ($parts.Count -eq 5) { + $commits += @{ + Hash = $parts[0] + AuthorName = $parts[1] + AuthorEmail = $parts[2] + Date = $parts[3] + Message = $parts[4] + } + } +} + +Write-Host "Found $($commits.Count) commits today." +Write-Host "" + +foreach ($commit in $commits) { + $shortHash = $commit.Hash.Substring(0, 7) + Write-Host "Processing commit: $shortHash - $($commit.Message)" + + # Get commit details + $diffStat = & git -C $RepoPath show --stat $commit.Hash + $diffDetails = & git -C $RepoPath show $commit.Hash + + # Parse statistics + $filesChanged = @() + $totalInsertions = 0 + $totalDeletions = 0 + + foreach ($line in $diffStat -split "`n") { + if ($line -match '(\d+) insertion') { + $totalInsertions += [int]$matches[1] + } + if ($line -match '(\d+) deletion') { + $totalDeletions += [int]$matches[1] + } + if ($line -match '^\s*(.*?)\s*\|\s*(\d+)') { + $filesChanged += @{ + File = $matches[1] + Lines = [int]$matches[2] + } + } + } + + # Generate filename + $dateStr = $commit.Date.Split(' ')[0].Replace('-', '') + $filename = "$dateStr`_$shortHash.md" + $outputPath = Join-Path $outputDir $filename + + # Generate Markdown content + $report = @" +# Commit Analysis Report - $shortHash + +## Basic Information + +| Item | Content | +|------|---------| +| Commit Hash | `` $($commit.Hash) `` | +| Author | $($commit.AuthorName) ($($commit.AuthorEmail)) | +| Commit Time | $($commit.Date) | + +## Commit Message + +$($commit.Message) + +## Change Statistics + +| Metric | Value | +|--------|-------| +| Files Changed | $($filesChanged.Count) | +| Lines Added | +$totalInsertions | +| Lines Removed | -$totalDeletions | + +## Modified Files + +"@ + + foreach ($file in $filesChanged) { + $report += "- $($file.File) ($($file.Lines) lines)`n" + } + + $report += @" + +## Detailed Changes + +```diff +$diffDetails +``` + +## Code Review Checklist + +> This section is auto-generated, manual review recommended + +- [ ] Check for potential bugs +- [ ] Verify code follows project standards +- [ ] Test new functionality if applicable +- [ ] Check for security issues + +--- + +*Report generated: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")* +"@ + + # Save file + [System.IO.File]::WriteAllText($outputPath, $report, [System.Text.Encoding]::UTF8) + Write-Host " Report saved: $outputPath" +} + +Write-Host "" +Write-Host "Done!" diff --git a/scripts/analyze_commits.py b/scripts/analyze_commits.py new file mode 100644 index 0000000..27daf86 --- /dev/null +++ b/scripts/analyze_commits.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +分析当天 Git 提交并生成 Markdown 报告的脚本 +""" + +import os +import subprocess +import sys +from datetime import datetime +import re + + +def run_command(cmd, cwd=None): + """运行命令并返回输出""" + try: + result = subprocess.run( + cmd, + shell=True, + capture_output=True, + text=True, + cwd=cwd, + encoding='utf-8', + errors='replace' + ) + return result.stdout, result.stderr, result.returncode + except Exception as e: + return "", str(e), 1 + + +def get_today_commits(repo_path): + """获取当天的所有提交""" + today_start = datetime.now().strftime('%Y-%m-%dT00:00:00') + today_end = datetime.now().strftime('%Y-%m-%dT23:59:59') + + cmd = f'git log --since="{today_start}" --until="{today_end}" --pretty=format:"%H|%an|%ae|%ad|%s" --date=iso' + stdout, stderr, code = run_command(cmd, cwd=repo_path) + + if code != 0: + print(f"Error getting commits: {stderr}") + return [] + + commits = [] + for line in stdout.strip().split('\n'): + if not line: + continue + parts = line.split('|', 4) + if len(parts) == 5: + commits.append({ + 'hash': parts[0], + 'author_name': parts[1], + 'author_email': parts[2], + 'date': parts[3], + 'message': parts[4] + }) + return commits + + +def get_commit_diff(repo_path, commit_hash): + """获取提交的详细 diff""" + cmd = f'git show --stat {commit_hash}' + stdout, _, _ = run_command(cmd, cwd=repo_path) + return stdout + + +def get_commit_details(repo_path, commit_hash): + """获取提交的详细信息""" + cmd = f'git show {commit_hash}' + stdout, _, _ = run_command(cmd, cwd=repo_path) + return stdout + + +def parse_diff_stats(diff_stat): + """解析 diff --stat 的输出""" + files_changed = [] + total_insertions = 0 + total_deletions = 0 + + lines = diff_stat.strip().split('\n') + for line in lines: + match = re.search(r'(\d+) insertion', line) + if match: + total_insertions += int(match.group(1)) + match = re.search(r'(\d+) deletion', line) + if match: + total_deletions += int(match.group(1)) + + file_match = re.match(r'^\s*(.*?)\s*\|\s*(\d+)', line) + if file_match: + files_changed.append({ + 'file': file_match.group(1), + 'lines': int(file_match.group(2)) + }) + + return { + 'files': files_changed, + 'insertions': total_insertions, + 'deletions': total_deletions + } + + +def generate_markdown_report(commit, diff_stat, diff_details): + """生成 Markdown 报告""" + short_hash = commit['hash'][:7] + date_str = commit['date'].split(' ')[0].replace('-', '') + + report = f"""# 提交分析报告 - {short_hash} + +## 基本信息 + +| 项目 | 内容 | +|------|------| +| 提交哈希 | `{commit['hash']}` | +| 作者 | {commit['author_name']} ({commit['author_email']}) | +| 提交时间 | {commit['date']} | + +## 提交信息 + +{commit['message']} + +## 变更统计 + +| 指标 | 数值 | +|------|------| +| 修改文件数 | {len(diff_stat['files'])} | +| 新增行数 | +{diff_stat['insertions']} | +| 删除行数 | -{diff_stat['deletions']} | + +## 修改文件列表 + +""" + + for file_info in diff_stat['files']: + report += f"- {file_info['file']} ({file_info['lines']} 行)\n" + + report += f""" +## 详细变更 + +```diff +{diff_details} +``` + +## 代码审查要点 + +> 此部分为自动生成,建议人工审查确认 + +- [ ] 检查是否有潜在的 bug +- [ ] 确认代码符合项目规范 +- [ ] 验证是否有需要测试的新功能 +- [ ] 检查是否有安全问题 + +--- + +*报告生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}* +""" + + return report, f"{date_str}_{short_hash}.md" + + +def main(): + repo_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + output_dir = os.path.join(repo_path, 'docs', 'auto_commit_md') + + # 创建输出目录 + os.makedirs(output_dir, exist_ok=True) + + print(f"分析仓库: {repo_path}") + print(f"输出目录: {output_dir}") + print() + + # 获取当天的提交 + commits = get_today_commits(repo_path) + + if not commits: + print("今天没有新提交。") + return 0 + + print(f"找到 {len(commits)} 个今天的提交。") + print() + + for commit in commits: + print(f"处理提交: {commit['hash'][:7]} - {commit['message']}") + + # 获取提交详情 + diff_stat = get_commit_diff(repo_path, commit['hash']) + diff_details = get_commit_details(repo_path, commit['hash']) + + # 解析统计信息 + stats = parse_diff_stats(diff_stat) + + # 生成报告 + report_content, filename = generate_markdown_report(commit, stats, diff_details) + + # 保存文件 + output_path = os.path.join(output_dir, filename) + with open(output_path, 'w', encoding='utf-8') as f: + f.write(report_content) + + print(f" 报告已保存: {output_path}") + + print() + print("完成!") + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/analyze_git_commits.py b/scripts/analyze_git_commits.py new file mode 100644 index 0000000..211c8e5 --- /dev/null +++ b/scripts/analyze_git_commits.py @@ -0,0 +1,600 @@ +#!/usr/bin/env python3 +""" +Git Commit 深度分析工具 +用于解析 Git 对象文件并生成详细的代码变更分析报告 +""" + +import zlib +import os +import re +import json +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Tuple, Optional, Any +from dataclasses import dataclass, field +from collections import defaultdict + + +@dataclass +class GitObject: + """Git 对象基类""" + obj_type: str + content: bytes + raw_data: bytes + + +@dataclass +class CommitInfo: + """提交信息""" + hash: str + parent: Optional[str] + tree: str + author: str + email: str + timestamp: int + timezone: str + message: str + changes: List[Dict] = field(default_factory=list) + stats: Dict = field(default_factory=dict) + + +@dataclass +class FileChange: + """文件变更信息""" + path: str + change_type: str # added, modified, deleted, renamed + old_path: Optional[str] = None + additions: int = 0 + deletions: int = 0 + diff_content: str = "" + + +class GitObjectParser: + """Git 对象解析器""" + + def __init__(self, repo_path: str): + self.repo_path = Path(repo_path) + self.objects_path = self.repo_path / ".git" / "objects" + self.commit_cache: Dict[str, CommitInfo] = {} + self.tree_cache: Dict[str, Dict[str, str]] = {} + + def read_object(self, obj_hash: str) -> Optional[GitObject]: + """读取并解压缩 Git 对象""" + if len(obj_hash) < 4: + return None + + obj_dir = obj_hash[:2] + obj_file = obj_hash[2:] + obj_path = self.objects_path / obj_dir / obj_file + + if not obj_path.exists(): + return None + + try: + with open(obj_path, 'rb') as f: + compressed_data = f.read() + + # 解压缩 zlib + decompressed = zlib.decompress(compressed_data) + + # 解析对象头和内容 + null_idx = decompressed.index(b'\x00') + header = decompressed[:null_idx].decode('utf-8') + content = decompressed[null_idx + 1:] + + obj_type = header.split()[0] + + return GitObject(obj_type=obj_type, content=content, raw_data=decompressed) + except Exception as e: + print(f"Error reading object {obj_hash}: {e}") + return None + + def parse_commit(self, commit_hash: str) -> Optional[CommitInfo]: + """解析 commit 对象""" + if commit_hash in self.commit_cache: + return self.commit_cache[commit_hash] + + obj = self.read_object(commit_hash) + if not obj or obj.obj_type != 'commit': + return None + + try: + content = obj.content.decode('utf-8', errors='replace') + lines = content.split('\n') + + parent = None + tree = None + author = None + email = None + timestamp = None + timezone = None + message_lines = [] + + in_message = False + for line in lines: + if in_message: + message_lines.append(line) + elif line.startswith('tree '): + tree = line[5:].strip() + elif line.startswith('parent '): + parent = line[7:].strip() + elif line.startswith('author '): + # author name timestamp timezone + match = re.match(r'author (.+) <(.+)> (\d+) ([+-]\d+)', line) + if match: + author = match.group(1) + email = match.group(2) + timestamp = int(match.group(3)) + timezone = match.group(4) + elif line == '': + in_message = True + + message = '\n'.join(message_lines).strip() + + commit_info = CommitInfo( + hash=commit_hash, + parent=parent, + tree=tree, + author=author or "Unknown", + email=email or "", + timestamp=timestamp or 0, + timezone=timezone or "", + message=message + ) + + self.commit_cache[commit_hash] = commit_info + return commit_info + + except Exception as e: + print(f"Error parsing commit {commit_hash}: {e}") + return None + + def parse_tree(self, tree_hash: str) -> Dict[str, str]: + """解析 tree 对象,返回文件路径到 blob hash 的映射""" + if tree_hash in self.tree_cache: + return self.tree_cache[tree_hash] + + obj = self.read_object(tree_hash) + if not obj or obj.obj_type != 'tree': + return {} + + entries = {} + content = obj.content + idx = 0 + + while idx < len(content): + # 查找空格分隔符 + space_idx = content.find(b' ', idx) + if space_idx == -1: + break + + mode = content[idx:space_idx].decode('utf-8') + + # 查找 null 分隔符 + null_idx = content.find(b'\x00', space_idx) + if null_idx == -1: + break + + name = content[space_idx + 1:null_idx].decode('utf-8', errors='replace') + + # 读取 20 字节的 SHA + sha_start = null_idx + 1 + sha_end = sha_start + 20 + if sha_end > len(content): + break + + sha = content[sha_start:sha_end].hex() + entries[name] = sha + + idx = sha_end + + self.tree_cache[tree_hash] = entries + return entries + + def get_blob_content(self, blob_hash: str) -> Optional[str]: + """获取 blob 对象的内容""" + obj = self.read_object(blob_hash) + if not obj or obj.obj_type != 'blob': + return None + try: + return obj.content.decode('utf-8', errors='replace') + except: + return None + + def compare_trees(self, old_tree: str, new_tree: str) -> List[FileChange]: + """比较两个 tree 对象,返回文件变更列表""" + old_files = self.parse_tree(old_tree) if old_tree else {} + new_files = self.parse_tree(new_tree) if new_tree else {} + + changes = [] + + # 查找新增和修改的文件 + for path, new_hash in new_files.items(): + if path not in old_files: + changes.append(FileChange(path=path, change_type='added')) + elif old_files[path] != new_hash: + changes.append(FileChange(path=path, change_type='modified')) + + # 查找删除的文件 + for path in old_files: + if path not in new_files: + changes.append(FileChange(path=path, change_type='deleted')) + + return changes + + def get_commit_changes(self, commit_hash: str) -> Tuple[List[FileChange], Dict]: + """获取提交的所有变更""" + commit = self.parse_commit(commit_hash) + if not commit: + return [], {} + + # 获取当前提交的 tree + current_tree = self.parse_tree(commit.tree) + + # 获取父提交的 tree + parent_tree = {} + if commit.parent: + parent_commit = self.parse_commit(commit.parent) + if parent_commit: + parent_tree = self.parse_tree(parent_commit.tree) + + changes = [] + stats = {'added': 0, 'modified': 0, 'deleted': 0, 'total_additions': 0, 'total_deletions': 0} + + # 比较 tree + all_paths = set(current_tree.keys()) | set(parent_tree.keys()) + + for path in all_paths: + if path in current_tree and path not in parent_tree: + # 新增文件 + changes.append(FileChange(path=path, change_type='added')) + stats['added'] += 1 + elif path not in current_tree and path in parent_tree: + # 删除文件 + changes.append(FileChange(path=path, change_type='deleted')) + stats['deleted'] += 1 + elif current_tree.get(path) != parent_tree.get(path): + # 修改文件 + changes.append(FileChange(path=path, change_type='modified')) + stats['modified'] += 1 + + return changes, stats + + +class CommitAnalyzer: + """提交分析器""" + + def __init__(self, repo_path: str): + self.parser = GitObjectParser(repo_path) + self.repo_path = Path(repo_path) + + def analyze_commit(self, commit_hash: str) -> Dict[str, Any]: + """分析单个提交""" + commit = self.parser.parse_commit(commit_hash) + if not commit: + return {} + + changes, stats = self.parser.get_commit_changes(commit_hash) + + # 分析文件类型 + file_types = defaultdict(int) + for change in changes: + ext = Path(change.path).suffix or 'no_extension' + file_types[ext] += 1 + + # 分析变更的重要性 + importance = self._assess_importance(commit.message, changes, stats) + + # 提取关键代码片段 + key_snippets = self._extract_key_snippets(changes) + + return { + 'commit_hash': commit_hash, + 'message': commit.message, + 'author': commit.author, + 'email': commit.email, + 'timestamp': commit.timestamp, + 'date': datetime.fromtimestamp(commit.timestamp).strftime('%Y-%m-%d %H:%M:%S'), + 'parent': commit.parent, + 'changes': [ + { + 'path': c.path, + 'type': c.change_type, + 'additions': c.additions, + 'deletions': c.deletions + } + for c in changes + ], + 'stats': stats, + 'file_types': dict(file_types), + 'importance': importance, + 'key_snippets': key_snippets, + 'impact_analysis': self._analyze_impact(changes, commit.message), + 'review_points': self._generate_review_points(changes, commit.message) + } + + def _assess_importance(self, message: str, changes: List[FileChange], stats: Dict) -> str: + """评估提交的重要性""" + message_lower = message.lower() + + # 检查关键关键词 + critical_keywords = ['fix', 'bug', 'security', 'crash', 'memory leak', 'deadlock'] + feature_keywords = ['feat', 'feature', 'add', 'implement', 'new'] + refactor_keywords = ['refactor', 'restructure', 'cleanup', 'optimize'] + + if any(kw in message_lower for kw in critical_keywords): + return 'critical' + elif any(kw in message_lower for kw in feature_keywords): + return 'feature' + elif stats.get('added', 0) + stats.get('modified', 0) + stats.get('deleted', 0) > 20: + return 'major' + elif any(kw in message_lower for kw in refactor_keywords): + return 'refactor' + else: + return 'minor' + + def _extract_key_snippets(self, changes: List[FileChange]) -> List[Dict]: + """提取关键代码片段""" + snippets = [] + + for change in changes[:10]: # 限制分析的文件数量 + if change.change_type == 'deleted': + continue + + # 尝试读取文件内容 + file_path = self.repo_path / change.path + if file_path.exists() and file_path.is_file(): + try: + with open(file_path, 'r', encoding='utf-8', errors='replace') as f: + content = f.read() + + # 提取文件的基本信息 + lines = content.split('\n') + snippet = { + 'file': change.path, + 'type': change.change_type, + 'lines_count': len(lines), + 'preview': '\n'.join(lines[:30]) if len(lines) > 30 else content + } + snippets.append(snippet) + except Exception: + pass + + return snippets + + def _analyze_impact(self, changes: List[FileChange], message: str) -> List[str]: + """分析变更对项目的影响""" + impacts = [] + + # 分析受影响的模块 + affected_modules = set() + for change in changes: + parts = change.path.split('/') + if len(parts) > 1: + affected_modules.add(parts[0]) + + if affected_modules: + impacts.append(f"受影响的模块: {', '.join(sorted(affected_modules))}") + + # 分析文件类型影响 + file_types = defaultdict(int) + for change in changes: + ext = Path(change.path).suffix + if ext: + file_types[ext] += 1 + + if '.cs' in file_types: + impacts.append(f"涉及 {file_types['.cs']} 个 C# 文件变更") + if '.axaml' in file_types or '.xaml' in file_types: + impacts.append("涉及 UI/XAML 文件变更") + if '.md' in file_types: + impacts.append("涉及文档更新") + + # 根据提交消息分析 + message_lower = message.lower() + if 'fix' in message_lower: + impacts.append("这是一个修复性提交,可能解决现有问题") + if 'feat' in message_lower or 'feature' in message_lower: + impacts.append("这是一个功能新增提交,扩展了项目能力") + if 'refactor' in message_lower: + impacts.append("这是一个重构提交,改善了代码结构") + if 'test' in message_lower: + impacts.append("涉及测试相关变更") + + return impacts + + def _generate_review_points(self, changes: List[FileChange], message: str) -> List[str]: + """生成代码审查要点""" + points = [] + + # 检查大文件变更 + large_files = [c for c in changes if c.additions + c.deletions > 100] + if large_files: + points.append(f"注意: 有 {len(large_files)} 个文件变更超过 100 行,需要仔细审查") + + # 检查关键文件 + critical_patterns = ['Program.cs', 'App.axaml', 'MainWindow', 'Core', 'Service'] + for change in changes: + for pattern in critical_patterns: + if pattern in change.path: + points.append(f"关键文件变更: {change.path} - 需要特别关注") + break + + # 检查提交消息质量 + if len(message) < 10: + points.append("提交消息较短,建议提供更详细的变更说明") + + if 'wip' in message.lower() or 'todo' in message.lower(): + points.append("提交包含 WIP/TODO 标记,确认是否已完成") + + # 检查文件删除 + deleted = [c for c in changes if c.change_type == 'deleted'] + if deleted: + points.append(f"删除了 {len(deleted)} 个文件,确认是否有其他代码依赖这些文件") + + return points + + +def generate_markdown_report(analysis: Dict[str, Any]) -> str: + """生成 Markdown 格式的分析报告""" + lines = [] + + # 标题 + lines.append(f"# Commit 深度分析报告") + lines.append(f"") + lines.append(f"**提交哈希**: `{analysis['commit_hash']}`") + lines.append(f"**提交时间**: {analysis['date']}") + lines.append(f"**作者**: {analysis['author']} <{analysis['email']}>") + lines.append(f"**重要性**: {analysis['importance'].upper()}") + lines.append(f"") + + # 提交消息 + lines.append(f"## 提交消息") + lines.append(f"```") + lines.append(analysis['message']) + lines.append(f"```") + lines.append(f"") + + # 变更统计 + lines.append(f"## 变更统计") + stats = analysis['stats'] + lines.append(f"- **新增文件**: {stats.get('added', 0)}") + lines.append(f"- **修改文件**: {stats.get('modified', 0)}") + lines.append(f"- **删除文件**: {stats.get('deleted', 0)}") + lines.append(f"") + + # 文件类型分布 + if analysis.get('file_types'): + lines.append(f"### 文件类型分布") + for ext, count in sorted(analysis['file_types'].items(), key=lambda x: -x[1]): + lines.append(f"- `{ext}`: {count} 个文件") + lines.append(f"") + + # 变更文件列表 + if analysis.get('changes'): + lines.append(f"## 变更文件列表") + lines.append(f"| 文件路径 | 变更类型 |") + lines.append(f"|---------|---------|") + type_map = {'added': '新增', 'modified': '修改', 'deleted': '删除'} + for change in analysis['changes'][:50]: # 限制显示数量 + change_type = type_map.get(change['type'], change['type']) + lines.append(f"| `{change['path']}` | {change_type} |") + lines.append(f"") + + # 影响分析 + if analysis.get('impact_analysis'): + lines.append(f"## 影响分析") + for impact in analysis['impact_analysis']: + lines.append(f"- {impact}") + lines.append(f"") + + # 代码审查要点 + if analysis.get('review_points'): + lines.append(f"## 代码审查要点") + for point in analysis['review_points']: + lines.append(f"- ⚠️ {point}") + lines.append(f"") + + # 关键代码片段 + if analysis.get('key_snippets'): + lines.append(f"## 关键代码片段") + for snippet in analysis['key_snippets'][:5]: + lines.append(f"### {snippet['file']}") + lines.append(f"- **类型**: {snippet['type']}") + lines.append(f"- **行数**: {snippet['lines_count']}") + lines.append(f"") + lines.append(f"```") + lines.append(snippet['preview'][:2000]) # 限制预览长度 + lines.append(f"```") + lines.append(f"") + + return '\n'.join(lines) + + +def main(): + """主函数""" + repo_path = r"d:\github\LanMountainDesktop" + output_dir = Path(repo_path) / "docs" / "auto_commit_md" + + # 确保输出目录存在 + output_dir.mkdir(parents=True, exist_ok=True) + + # 读取 HEAD 日志 + head_log_path = Path(repo_path) / ".git" / "logs" / "HEAD" + if not head_log_path.exists(): + print(f"错误: 找不到 HEAD 日志文件: {head_log_path}") + return + + # 解析 HEAD 日志获取所有 commit + commits = [] + with open(head_log_path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if not line: + continue + + # 解析日志行 + # 格式: old_hash new_hash name timestamp timezone\taction: message + parts = line.split('\t') + if len(parts) < 2: + continue + + meta_part = parts[0] + action_part = parts[1] + + meta_tokens = meta_part.split() + if len(meta_tokens) < 5: + continue + + new_hash = meta_tokens[1] + + # 只处理 commit 操作 + if 'commit' in action_part or action_part.startswith('commit:'): + message = action_part.replace('commit:', '').strip() + commits.append({ + 'hash': new_hash, + 'message': message + }) + + print(f"找到 {len(commits)} 个 commit") + + # 初始化分析器 + analyzer = CommitAnalyzer(repo_path) + + # 分析每个 commit + for i, commit_info in enumerate(commits): + commit_hash = commit_info['hash'] + short_hash = commit_hash[:7] + + print(f"[{i+1}/{len(commits)}] 分析 commit: {short_hash} - {commit_info['message'][:50]}") + + try: + # 分析提交 + analysis = analyzer.analyze_commit(commit_hash) + if not analysis: + print(f" 跳过: 无法解析 commit {short_hash}") + continue + + # 生成报告 + report = generate_markdown_report(analysis) + + # 保存报告 + date_str = datetime.fromtimestamp(analysis['timestamp']).strftime('%Y%m%d') + filename = f"{date_str}_{short_hash}_deep_analysis.md" + output_path = output_dir / filename + + with open(output_path, 'w', encoding='utf-8') as f: + f.write(report) + + print(f" 已保存: {filename}") + + except Exception as e: + print(f" 错误: 分析 commit {short_hash} 时出错: {e}") + import traceback + traceback.print_exc() + + print("\n分析完成!") + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_commit_docs.py b/scripts/generate_commit_docs.py new file mode 100644 index 0000000..d4a0e0f --- /dev/null +++ b/scripts/generate_commit_docs.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +解析 Git HEAD 日志文件并生成 Markdown 提交分析报告 +""" + +import re +import os +from datetime import datetime, timezone, timedelta +from pathlib import Path + +def parse_head_log(log_content): + """ + 解析 HEAD 日志内容,提取所有 commit 类型的提交 + + 格式:old_hash new_hash author_name timestamp timezone\taction: message + """ + commits = [] + + # 匹配 commit 类型的行(包括 commit, commit (merge) 等) + # 注意:message 部分可能包含中文,使用 .* 匹配 + pattern = r'^([a-f0-9]{40}) ([a-f0-9]{40}) (.+) <([^>]+)> (\d+) ([+-]\d{4})\tcommit.*?: (.+)$' + + for line in log_content.strip().split('\n'): + line = line.strip() + if not line: + continue + + match = re.match(pattern, line) + if match: + old_hash, new_hash, author_name, author_email, timestamp, tz_offset, message = match.groups() + + # 解析时间戳 + ts = int(timestamp) + # 解析时区偏移 + tz_hours = int(tz_offset[:3]) + tz_mins = int(tz_offset[0] + tz_offset[3:5]) + tz = timezone(timedelta(hours=tz_hours, minutes=tz_mins)) + dt = datetime.fromtimestamp(ts, tz) + + commits.append({ + 'old_hash': old_hash, + 'new_hash': new_hash, + 'short_hash': new_hash[:7], + 'author_name': author_name, + 'author_email': author_email, + 'timestamp': ts, + 'datetime': dt, + 'date_str': dt.strftime('%Y-%m-%d'), + 'time_str': dt.strftime('%H:%M:%S'), + 'timezone': tz_offset, + 'message': message.strip() + }) + + return commits + + +def analyze_commit_type(message): + """ + 分析提交类型 + + 支持的类型: + - feat: 新功能 + - fix: 修复 + - docs: 文档 + - style: 格式 + - refactor: 重构 + - perf: 性能优化 + - test: 测试 + - chore: 构建/工具 + - ci: CI/CD + - revert: 回滚 + - change/changed: 变更 + - remove/removed: 移除 + """ + message_lower = message.lower() + + # 定义类型映射 + type_patterns = [ + (r'^feat[.:\s]', 'feat', '新功能 (Feature)', '添加新功能或特性'), + (r'^fix[.:\s]', 'fix', '修复 (Bug Fix)', '修复问题或缺陷'), + (r'^docs[.:\s]', 'docs', '文档 (Documentation)', '文档更新'), + (r'^style[.:\s]', 'style', '格式 (Style)', '代码格式调整'), + (r'^refactor[.:\s]', 'refactor', '重构 (Refactor)', '代码重构'), + (r'^perf[.:\s]', 'perf', '性能优化 (Performance)', '性能改进'), + (r'^test[.:\s]', 'test', '测试 (Test)', '测试相关'), + (r'^chore[.:\s]', 'chore', '构建/工具 (Chore)', '构建流程或工具更新'), + (r'^ci[.:\s]', 'ci', 'CI/CD', '持续集成/部署'), + (r'^revert[.:\s]', 'revert', '回滚 (Revert)', '撤销之前的提交'), + (r'^change[d]?[.:\s]', 'change', '变更 (Change)', '功能或行为变更'), + (r'^remove[d]?[.:\s]', 'remove', '移除 (Remove)', '删除代码或功能'), + (r'^update[.:\s]', 'update', '更新 (Update)', '更新依赖或配置'), + (r'^add[.:\s]', 'add', '添加 (Add)', '添加新内容'), + (r'^introduce[.:\s]', 'introduce', '引入 (Introduce)', '引入新模块或概念'), + (r'^support[.:\s]', 'support', '支持 (Support)', '增加支持'), + (r'^migrate[.:\s]', 'migrate', '迁移 (Migrate)', '迁移或升级'), + (r'^bump[.:\s]', 'bump', '版本升级 (Bump)', '依赖版本升级'), + (r'^enable[.:\s]', 'enable', '启用 (Enable)', '启用功能'), + (r'^use[.:\s]', 'use', '使用 (Use)', '使用某技术或方法'), + (r'^make[.:\s]', 'make', '调整 (Make)', '调整实现'), + (r'^lock[.:\s]', 'lock', '锁定 (Lock)', '锁定特定行为'), + (r'^stamp[.:\s]', 'stamp', '标记 (Stamp)', '版本标记'), + (r'^harden[.:\s]', 'harden', '加固 (Harden)', '安全性/稳定性加固'), + (r'^resolve[.:\s]', 'resolve', '解决 (Resolve)', '解决问题'), + (r'^simplify[.:\s]', 'simplify', '简化 (Simplify)', '简化实现'), + (r'^move[.:\s]', 'move', '移动 (Move)', '文件或代码移动'), + (r'^rebuild[.:\s]', 'rebuild', '重建 (Rebuild)', '重建系统或流程'), + (r'^refresh[.:\s]', 'refresh', '刷新 (Refresh)', '刷新内容'), + (r'^normalize[.:\s]', 'normalize', '规范化 (Normalize)', '规范化处理'), + (r'^redesign[.:\s]', 'redesign', '重新设计 (Redesign)', 'UI/架构重新设计'), + ] + + for pattern, code, name, description in type_patterns: + if re.match(pattern, message_lower): + return { + 'code': code, + 'name': name, + 'description': description + } + + # 版本号提交(如 0.7.9.1, 0.8.0 等) + if re.match(r'^\d+\.\d+', message): + return { + 'code': 'release', + 'name': '版本发布 (Release)', + 'description': '版本号更新或发布' + } + + # 默认类型 + return { + 'code': 'other', + 'name': '其他 (Other)', + 'description': '其他类型的提交' + } + + +def generate_commit_markdown(commit): + """为单个提交生成 Markdown 文档""" + + commit_type = analyze_commit_type(commit['message']) + + # 提取提交摘要(第一行或前50个字符) + summary = commit['message'].split('\n')[0][:100] + + # 生成分析内容 + md_content = f"""# 提交分析报告 + +## 1. 提交基本信息 + +| 属性 | 值 | +|------|-----| +| **完整哈希** | `{commit['new_hash']}` | +| **短哈希** | `{commit['short_hash']}` | +| **作者** | {commit['author_name']} <{commit['author_email']}> | +| **提交日期** | {commit['date_str']} | +| **提交时间** | {commit['time_str']} | +| **时区** | {commit['timezone']} | +| **父提交** | `{commit['old_hash']}` | + +## 2. 提交信息摘要 + +``` +{commit['message']} +``` + +**摘要**: {summary} + +## 3. 变更类型分析 + +| 属性 | 值 | +|------|-----| +| **类型代码** | `{commit_type['code']}` | +| **类型名称** | {commit_type['name']} | +| **类型说明** | {commit_type['description']} | + +## 4. 提交内容解读 + +""" + + # 根据提交类型添加解读内容 + if commit_type['code'] == 'feat': + md_content += f""" +这是一个**新功能**提交,引入了新的功能或特性。 + +**可能涉及的变更**: +- 新增功能模块或组件 +- 新增 API 接口 +- 新增用户界面元素 +- 新增配置选项 + +**建议关注**: +- 新功能的实现方式 +- 是否包含相应的测试用例 +- 文档是否同步更新 +""" + elif commit_type['code'] == 'fix': + md_content += f""" +这是一个**问题修复**提交,修复了系统中的某个问题或缺陷。 + +**可能涉及的变更**: +- 修复程序错误 (Bug) +- 修复 UI 显示问题 +- 修复性能问题 +- 修复兼容性问题 + +**建议关注**: +- 修复的问题描述 +- 修复方案是否合理 +- 是否引入了回归风险 +""" + elif commit_type['code'] == 'docs': + md_content += f""" +这是一个**文档更新**提交,更新了项目文档。 + +**可能涉及的变更**: +- README 更新 +- API 文档更新 +- 注释完善 +- 新增文档文件 + +**建议关注**: +- 文档内容准确性 +- 文档格式规范性 +""" + elif commit_type['code'] == 'refactor': + md_content += f""" +这是一个**代码重构**提交,对代码进行了重构优化。 + +**可能涉及的变更**: +- 代码结构优化 +- 提取公共方法 +- 重命名变量/类 +- 消除重复代码 + +**建议关注**: +- 重构是否保持功能一致性 +- 代码可读性是否提升 +""" + elif commit_type['code'] == 'ci': + md_content += f""" +这是一个**CI/CD**提交,更新了持续集成/部署配置。 + +**可能涉及的变更**: +- GitHub Actions 工作流更新 +- 构建脚本调整 +- 发布流程优化 +- 自动化测试配置 + +**建议关注**: +- CI 流程是否正常执行 +- 部署流程是否受影响 +""" + elif commit_type['code'] == 'release': + md_content += f""" +这是一个**版本发布**提交,标记了版本号更新。 + +**版本号**: {commit['message']} + +**可能涉及的变更**: +- 版本号更新 +- 发布打包 +- 变更日志更新 +- 标签创建 + +**建议关注**: +- 版本号是否符合语义化版本规范 +- 变更日志是否完整 +""" + elif commit_type['code'] == 'chore': + md_content += f""" +这是一个**构建/工具**提交,更新了构建流程或开发工具。 + +**可能涉及的变更**: +- 依赖包更新 +- 构建配置调整 +- 开发工具配置 +- 脚本文件更新 + +**建议关注**: +- 构建是否正常 +- 依赖兼容性 +""" + elif commit_type['code'] == 'change': + md_content += f""" +这是一个**功能变更**提交,修改了现有功能的行为或实现。 + +**可能涉及的变更**: +- 功能行为调整 +- 配置项变更 +- 接口变更 +- 默认值修改 + +**建议关注**: +- 变更是否向后兼容 +- 是否需要更新文档 +""" + else: + md_content += f""" +这是一个**{commit_type['name']}**提交。 + +**提交内容**: +{commit['message']} + +**建议**: +- 查看具体代码变更以了解详细内容 +- 结合项目上下文理解提交意图 +""" + + # 添加页脚 + md_content += f""" + +--- + +*此报告由自动提交分析工具生成* +*生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}* +""" + + return md_content + + +def main(): + """主函数""" + # 项目根目录 + repo_root = Path('d:/github/LanMountainDesktop') + + # 读取 HEAD 日志文件 + head_log_path = repo_root / '.git' / 'logs' / 'HEAD' + output_dir = repo_root / 'docs' / 'auto_commit_md' + + print(f"读取日志文件: {head_log_path}") + + if not head_log_path.exists(): + print(f"错误: 日志文件不存在: {head_log_path}") + return + + with open(head_log_path, 'r', encoding='utf-8') as f: + log_content = f.read() + + # 解析提交记录 + commits = parse_head_log(log_content) + print(f"解析到 {len(commits)} 个 commit 类型提交") + + # 确保输出目录存在 + output_dir.mkdir(parents=True, exist_ok=True) + + # 统计信息 + generated_count = 0 + skipped_count = 0 + error_count = 0 + + # 为每个提交生成 Markdown 文件 + for commit in commits: + # 文件名格式: YYYYMMDD_.md + filename = f"{commit['date_str'].replace('-', '')}_{commit['short_hash']}.md" + filepath = output_dir / filename + + # 如果文件已存在,跳过 + if filepath.exists(): + print(f"跳过 (已存在): {filename}") + skipped_count += 1 + continue + + try: + # 生成 Markdown 内容 + md_content = generate_commit_markdown(commit) + + # 写入文件 + with open(filepath, 'w', encoding='utf-8') as f: + f.write(md_content) + + print(f"生成: {filename} - {commit['message'][:50]}") + generated_count += 1 + + except Exception as e: + print(f"错误: 生成 {filename} 失败: {e}") + error_count += 1 + + # 打印统计信息 + print("\n" + "="*50) + print("生成完成!") + print(f" - 新生成: {generated_count} 个文件") + print(f" - 已跳过: {skipped_count} 个文件") + print(f" - 错误: {error_count} 个文件") + print(f" - 总计: {len(commits)} 个提交") + print(f"\n输出目录: {output_dir}") + + +if __name__ == '__main__': + main() diff --git a/scripts/generate_commit_reports.py b/scripts/generate_commit_reports.py new file mode 100644 index 0000000..5c19795 --- /dev/null +++ b/scripts/generate_commit_reports.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +import subprocess +import os +import re +from datetime import datetime +from pathlib import Path + + +def run_git_command(cmd, cwd=None): + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, cwd=cwd) + if result.returncode != 0: + print(f"Git command failed: {cmd}") + print(f"Error: {result.stderr}") + return None + return result.stdout + + +def get_commits_since(since_date, until_date, cwd=None): + cmd = f'git log --since="{since_date}" --until="{until_date}" --pretty=format:"%H|%an|%ae|%ad|%s" --date=iso' + output = run_git_command(cmd, cwd) + if not output: + return [] + commits = [] + for line in output.strip().split('\n'): + if line: + parts = line.split('|', 4) + commits.append({ + 'hash': parts[0], + 'author': parts[1], + 'email': parts[2], + 'date': parts[3], + 'message': parts[4] + }) + return commits + + +def get_commit_diff(commit_hash, cwd=None): + cmd = f'git show {commit_hash}' + return run_git_command(cmd, cwd) + + +def get_commit_stat(commit_hash, cwd=None): + cmd = f'git show --stat {commit_hash}' + return run_git_command(cmd, cwd) + + +def analyze_diff(diff_text): + file_changes = [] + current_file = None + changes = {'insertions': 0, 'deletions': 0, 'files': 0} + + lines = diff_text.split('\n') + for line in lines: + if line.startswith('diff --git'): + if current_file: + file_changes.append(current_file) + filename = line.split(' ')[2][2:] + current_file = {'name': filename, 'insertions': 0, 'deletions': 0, 'hunks': []} + changes['files'] += 1 + elif line.startswith('+') and not line.startswith('+++'): + if current_file: + current_file['insertions'] += 1 + changes['insertions'] += 1 + elif line.startswith('-') and not line.startswith('---'): + if current_file: + current_file['deletions'] += 1 + changes['deletions'] += 1 + + if current_file: + file_changes.append(current_file) + + return file_changes, changes + + +def generate_markdown_report(commit, diff_text, stat_text, output_dir): + file_changes, summary = analyze_diff(diff_text) + + short_hash = commit['hash'][:7] + date_obj = datetime.fromisoformat(commit['date'].replace(' +0800', '')) + date_str = date_obj.strftime('%Y%m%d') + + filename = f"{date_str}_{short_hash}.md" + filepath = Path(output_dir) / filename + + markdown = f"""# Git 提交分析报告 + +## 基本信息 + +| 项目 | 内容 | +|------|------| +| 提交哈希 | `{commit['hash']}` | +| 短哈希 | `{short_hash}` | +| 作者 | {commit['author']} <{commit['email']}> | +| 提交时间 | {commit['date']} | + +## 提交信息 + +{commit['message']} + +## 变更统计 + +- **变更文件数**: {summary['files']} +- **新增行数**: +{summary['insertions']} +- **删除行数**: -{summary['deletions']} + +## 详细变更 + +""" + + if file_changes: + for fc in sorted(file_changes, key=lambda x: x['name']): + markdown += f"\n### `{fc['name']}`\n" + markdown += f"- 新增: +{fc['insertions']} 行\n" + markdown += f"- 删除: -{fc['deletions']} 行\n" + else: + markdown += "\n*无文件变更统计*\n" + + markdown += "\n## 完整 Diff\n\n```diff\n" + markdown += diff_text + markdown += "\n```\n" + + with open(filepath, 'w', encoding='utf-8') as f: + f.write(markdown) + + print(f"Generated: {filepath}") + return filepath + + +def main(): + repo_dir = Path(__file__).parent.parent + output_dir = repo_dir / 'docs' / 'auto_commit_md' + output_dir.mkdir(parents=True, exist_ok=True) + + today = datetime.now() + since_date = today.strftime('%Y-%m-%d 00:00:00') + until_date = today.strftime('%Y-%m-%d 23:59:59') + + print(f"Fetching commits from {since_date} to {until_date}...") + commits = get_commits_since(since_date, until_date, str(repo_dir)) + + if not commits: + print("No commits found for today.") + print("\nLet's check the latest commits instead...") + cmd = 'git log -3 --pretty=format:"%H|%an|%ae|%ad|%s" --date=iso' + output = run_git_command(cmd, str(repo_dir)) + if output: + commits = [] + for line in output.strip().split('\n'): + if line: + parts = line.split('|', 4) + commits.append({ + 'hash': parts[0], + 'author': parts[1], + 'email': parts[2], + 'date': parts[3], + 'message': parts[4] + }) + + if commits: + print(f"\nFound {len(commits)} commit(s) to analyze:\n") + for commit in commits: + print(f" - {commit['hash'][:7]}: {commit['message']}") + + print("\nGenerating reports...\n") + for commit in commits: + diff = get_commit_diff(commit['hash'], str(repo_dir)) + stat = get_commit_stat(commit['hash'], str(repo_dir)) + if diff: + generate_markdown_report(commit, diff, stat, str(output_dir)) + + print(f"\nDone! Reports saved to {output_dir}") + else: + print("No commits found.") + + +if __name__ == '__main__': + main()