diff --git a/LanMountainDesktop.Tests/DesktopEditCommitMathTests.cs b/LanMountainDesktop.Tests/DesktopEditCommitMathTests.cs new file mode 100644 index 0000000..bc5eea1 --- /dev/null +++ b/LanMountainDesktop.Tests/DesktopEditCommitMathTests.cs @@ -0,0 +1,15 @@ +using LanMountainDesktop.DesktopEditing; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class DesktopEditCommitMathTests +{ + [Fact] + public void IsPendingCommitValid_ReturnsTrueOnlyForMatchingActiveVersion() + { + Assert.True(DesktopEditCommitMath.IsPendingCommitValid(isPending: true, scheduledVersion: 4, currentVersion: 4)); + Assert.False(DesktopEditCommitMath.IsPendingCommitValid(isPending: false, scheduledVersion: 4, currentVersion: 4)); + Assert.False(DesktopEditCommitMath.IsPendingCommitValid(isPending: true, scheduledVersion: 4, currentVersion: 5)); + } +} diff --git a/LanMountainDesktop.Tests/DesktopPlacementMathTests.cs b/LanMountainDesktop.Tests/DesktopPlacementMathTests.cs new file mode 100644 index 0000000..23eedc8 --- /dev/null +++ b/LanMountainDesktop.Tests/DesktopPlacementMathTests.cs @@ -0,0 +1,138 @@ +using Avalonia; +using LanMountainDesktop.DesktopEditing; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class DesktopPlacementMathTests +{ + [Fact] + public void ComputeDragStartThreshold_UsesFloorAndCellScale() + { + Assert.Equal(10d, DesktopPlacementMath.ComputeDragStartThreshold(24)); + Assert.Equal(14.4d, DesktopPlacementMath.ComputeDragStartThreshold(80), 3); + } + + [Fact] + public void HasExceededThreshold_OnlyReturnsTrueAfterEnoughMovement() + { + var start = new Point(20, 20); + + Assert.False(DesktopPlacementMath.HasExceededThreshold(start, new Point(27, 25), 10)); + Assert.True(DesktopPlacementMath.HasExceededThreshold(start, new Point(31, 20), 10)); + } + + [Fact] + public void OcclusionHelpers_DetectPointAndRectOverlap() + { + var libraryBounds = new Rect(100, 100, 200, 160); + + Assert.True(DesktopPlacementMath.IsOccludedByComponentLibrary(new Point(120, 150), libraryBounds)); + Assert.False(DesktopPlacementMath.IsOccludedByComponentLibrary(new Point(80, 90), libraryBounds)); + Assert.True(DesktopPlacementMath.IsOccludedByComponentLibrary(new Rect(250, 120, 120, 80), libraryBounds)); + Assert.False(DesktopPlacementMath.IsOccludedByComponentLibrary(new Rect(10, 10, 40, 40), libraryBounds)); + } + + [Fact] + public void TryGetSnappedCell_ClampsInsideGridBounds() + { + var grid = new DesktopGridGeometry( + Origin: default, + CellSize: 80, + CellGap: 8, + ColumnCount: 4, + RowCount: 5); + + var result = DesktopPlacementMath.TryGetSnappedCell( + grid, + pointerInViewport: new Point(490, 520), + pointerOffset: new Point(10, 10), + widthCells: 2, + heightCells: 3, + out var column, + out var row); + + Assert.True(result); + Assert.Equal(2, column); + Assert.Equal(2, row); + } + + [Fact] + public void GetCellRect_MapsCellsToPixelRect() + { + var grid = new DesktopGridGeometry( + Origin: new Point(12, 24), + CellSize: 80, + CellGap: 8, + ColumnCount: 6, + RowCount: 8); + + var rect = DesktopPlacementMath.GetCellRect(grid, column: 2, row: 3, widthCells: 2, heightCells: 3); + + Assert.Equal(188, rect.X, 3); + Assert.Equal(288, rect.Y, 3); + Assert.Equal(168, rect.Width, 3); + Assert.Equal(256, rect.Height, 3); + } + + [Fact] + public void Session_DoesNotCommitWhilePointerIsStillInsideLibrary() + { + var session = DesktopEditSession.CreatePendingNew( + componentId: "demo", + pageIndex: 0, + widthCells: 2, + heightCells: 2, + startPointerInViewport: new Point(80, 80), + pointerOffsetInViewport: new Point(60, 60), + componentLibraryBounds: new Rect(0, 0, 220, 300)); + + session = session.WithCurrentPointer(new Point(130, 150)); + + Assert.True(session.HasExceededThreshold(DesktopPlacementMath.ComputeDragStartThreshold(80))); + Assert.True(session.IsPointerInsideComponentLibrary()); + Assert.False(session.CanCommit); + } + + [Fact] + public void Session_ResizePreviewStillBlocksWhenPointerRemainsInsideLibrary() + { + var session = DesktopEditSession.CreateResizingExisting( + componentId: "demo", + placementId: "placement-1", + pageIndex: 0, + widthCells: 2, + heightCells: 2, + startPointerInViewport: new Point(80, 80), + componentLibraryBounds: new Rect(0, 0, 220, 300)) + .WithCurrentPointer(new Point(130, 150)); + + Assert.True(session.IsPointerInsideComponentLibrary()); + Assert.False(session.CanCommit); + } + + [Fact] + public void HasCellPositionChanged_DetectsNoOpAndRealMoves() + { + Assert.False(DesktopPlacementMath.HasCellPositionChanged(2, 3, 2, 3)); + Assert.True(DesktopPlacementMath.HasCellPositionChanged(2, 3, 2, 4)); + } + + [Fact] + public void HasCellSpanChanged_DetectsNoOpAndRealResizes() + { + Assert.False(DesktopPlacementMath.HasCellSpanChanged(2, 3, 2, 3)); + Assert.True(DesktopPlacementMath.HasCellSpanChanged(2, 3, 3, 3)); + } + + [Fact] + public void CanCommitPlacement_BlocksWhenPlacementIsOccludedByLibrary() + { + var placementRect = new Rect(160, 110, 180, 140); + var occludingLibraryBounds = new Rect(120, 80, 240, 220); + var distantLibraryBounds = new Rect(420, 420, 80, 80); + + Assert.False(DesktopPlacementMath.CanCommitPlacement(placementRect, occludingLibraryBounds)); + Assert.True(DesktopPlacementMath.CanCommitPlacement(placementRect, distantLibraryBounds)); + } +} diff --git a/LanMountainDesktop/Assets/Documents/Privacy.md b/LanMountainDesktop/Assets/Documents/Privacy.md index a49e00b..5bd1a6e 100644 --- a/LanMountainDesktop/Assets/Documents/Privacy.md +++ b/LanMountainDesktop/Assets/Documents/Privacy.md @@ -1,54 +1,229 @@ -# 隐私与遥测说明 +# LanMountainDesktop 遥测隐私政策 -LanMountainDesktop 提供两类可选遥测能力: +**生效日期**:2026年3月22日\ +**最后更新**:2026年3月22日 -- 崩溃数据上传 -- 行为数据分析 +*** -这两个开关默认关闭。即使两项都关闭,应用仍会在首次启动时向 PostHog 发送一次最小化的启动基线事件,用于统计用户量。 +## 引言 -## 默认行为 +LanMountainDesktop(以下简称"本应用")由 灵方软件Lincube(以下简称"我们")开发和维护。我们深知用户隐私的重要性,并致力于保护您的个人信息安全。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的数据。 -当“崩溃数据上传”和“行为数据分析”都关闭时: +使用本应用即表示您同意本隐私政策的条款。如果您不同意本政策的任何部分,请停止使用本应用。 -- 仅首次启动会发送一次 `app_first_launch` 事件 -- 该事件只用于统计用户量 -- 事件时间由 PostHog 接入侧记录的请求时间和启动时间决定 -- 不会主动上传设备型号、操作系统细节、组件操作轨迹等详细信息 +*** -## 崩溃数据上传 +## 1. 数据收集范围 -当开启“崩溃数据上传”时,应用会把崩溃与未处理异常发送到 Sentry,用于分析稳定性问题。 +### 1.1 我们收集的数据 -上报内容可能包括: +本应用提供两类可选的数据收集功能: -- 异常堆栈和错误上下文 -- 应用版本与运行环境 -- 操作系统信息 -- 设备基础信息 -- 最近的日志尾部内容 +| 数据类型 | 收集方式 | 默认状态 | 用途 | +| ------ | ---- | ---- | -------- | +| 启动基线事件 | 自动收集 | 开启 | 统计用户量 | +| 崩溃数据 | 用户授权 | 关闭 | 分析稳定性问题 | +| 行为数据 | 用户授权 | 关闭 | 分析功能使用情况 | -应用退出或崩溃时,会尽量补充最后一次会话和日志信息,方便定位问题。 +### 1.2 启动基线事件 -## 行为数据分析 +无论您是否开启其他遥测选项,本应用会在首次启动时发送一次最小化的启动基线事件(`app_first_launch`),用于统计活跃用户量。该事件仅包含: -当开启“行为数据分析”时,应用会把关键行为事件发送到 PostHog,用于分析功能使用情况和会话路径。 +- 匿名安装标识符(Install ID) +- 应用版本号 +- 启动时间戳 -上报内容可能包括: +### 1.3 崩溃数据 -- 应用启动和退出时间 -- 会话开始与结束时间 -- 设置页打开、关闭和导航 -- 抽屉打开和关闭 -- 桌面组件的放置、移动、缩放、删除和编辑入口 +当您开启"崩溃数据上传"功能时,我们可能收集以下信息: -这些事件会被转换成 PostHog 可以直接接收和分析的事件格式,方便在 PostHog 中按事件流查看用户行为。桌面端的“回放”能力通过事件时间线重建,而不是浏览器式 Session Replay。 +- **异常信息**:异常类型、错误消息、堆栈跟踪 +- **应用信息**:应用版本、构建号、运行时环境 +- **系统信息**:操作系统版本、系统架构、可用内存 +- **设备信息**:设备型号、屏幕分辨率 +- **日志信息**:应用崩溃前的最近日志记录(可能包含您在使用过程中产生的操作记录) -## 身份与隐私控制 +### 1.4 行为数据 -应用会使用随机生成的匿名 install ID 和可刷新 telemetry ID 来区分安装与运行会话。 +当您开启"行为数据分析"功能时,我们可能收集以下信息: -- 刷新 telemetry ID 只会影响后续详细遥测 -- 关闭开关后,不会继续发送对应类别的详细遥测 -- IP 只会通过 Sentry / PostHog 的服务端接入侧自然记录,不会作为自定义字段重复上报 +- **会话信息**:应用启动/退出时间、会话持续时间 +- **功能使用**:设置页面访问、抽屉操作、组件库操作 +- **组件操作**:桌面组件的放置、移动、调整大小、删除操作 +- **界面交互**:页面切换、编辑模式进入/退出 +*** + +## 2. 数据使用目的 + +我们收集的数据将用于以下目的: + +### 2.1 启动基线事件 + +- 统计应用的用户数量和活跃度 +- 了解应用的安装分布情况 + +### 2.2 崩溃数据 + +- 诊断和修复应用崩溃问题 +- 提高应用的稳定性和可靠性 +- 识别和解决性能瓶颈 + +### 2.3 行为数据 + +- 了解用户如何使用本应用的功能 +- 改进用户体验和界面设计 +- 指导功能开发和优先级决策 +- 分析用户行为模式和趋势 + +*** + +## 3. 数据存储与传输 + +### 3.1 数据传输 + +您的数据将通过加密连接传输至以下第三方服务: + +| 服务提供商 | 服务类型 | 数据内容 | 隐私政策 | +| ------- | ---- | --------- | ----------------------------- | +| PostHog | 产品分析 | 启动事件、行为数据 | | +| Sentry | 错误监控 | 崩溃数据、异常信息 | | + +### 3.2 数据存储位置 + +数据存储于上述第三方服务的服务器,这些服务器可能位于中国境外。我们已与这些服务提供商签订数据处理协议,确保您的数据得到适当保护。 + +### 3.3 数据保留期限 + +- **启动基线事件**:保留期限由 PostHog 服务配置决定,通常为 13 个月 +- **崩溃数据**:保留期限由 Sentry 服务配置决定,通常为 90 天 +- **行为数据**:保留期限由 PostHog 服务配置决定,通常为 13 个月 + +*** + +## 4. 用户权利与控制 + +### 4.1 您的权利 + +根据适用的数据保护法律,您享有以下权利: + +- **知情权**:了解我们收集哪些数据及其用途 +- **访问权**:请求获取我们持有的您的个人数据副本 +- **更正权**:请求更正不准确或不完整的个人数据 +- **删除权**:请求删除您的个人数据 +- **撤回同意权**:随时撤回您对数据收集的同意 +- **数据可携带权**:以结构化格式接收您的个人数据 + +### 4.2 如何行使您的权利 + +您可以通过以下方式行使上述权利: + +1. **关闭遥测功能**:在应用设置 > 隐私设置中关闭相应开关 +2. **刷新遥测标识**:在应用设置 > 隐私设置中点击"刷新遥测 ID" +3. **联系我们**:通过 GitHub Issues 提交数据相关请求 + +### 4.3 功能控制 + +| 功能 | 控制方式 | 效果 | +| ------- | ---- | ----------- | +| 崩溃数据上传 | 设置开关 | 关闭后停止发送崩溃数据 | +| 行为数据分析 | 设置开关 | 关闭后停止发送行为数据 | +| 刷新遥测 ID | 手动触发 | 生成新的匿名标识符 | + +*** + +## 5. 身份标识 + +### 5.1 匿名标识符 + +我们使用以下匿名标识符来区分用户和会话: + +- **Install ID**:在应用首次安装时随机生成的唯一标识符,用于区分不同的安装实例 +- **Telemetry ID**:匿名标识符,用于关联遥测数据 + +### 5.2 标识符特性 + +- 这些标识符不包含您的真实身份信息 +- 标识符与您的个人身份(如姓名、邮箱、电话)无关联 + +*** + +## 6. 数据安全 + +### 6.1 安全措施 + +我们采取以下安全措施保护您的数据: + +- **传输加密**:所有数据传输均使用 TLS/HTTPS 加密 +- **访问控制**:限制对数据的访问权限,仅授权人员可访问 +- **匿名化处理**:使用匿名标识符而非个人身份信息 + +### 6.2 数据泄露响应 + +如发生数据泄露事件,我们将: + +1. 及时评估泄露的影响范围和严重程度 +2. 采取必要措施阻止进一步泄露 +3. 根据法律要求通知相关监管机构和受影响用户 + +*** + +## 7. 第三方服务 + +### 7.1 PostHog + +PostHog 是我们使用的产品分析平台,用于收集和分析用户行为数据。PostHog 的隐私政策请参阅: + +### 7.2 Sentry + +Sentry 是我们使用的错误监控平台,用于收集和分析崩溃数据。Sentry 的隐私政策请参阅: + +### 7.3 第三方责任 + +我们仅将上述第三方服务用于本政策所述目的。我们不对这些第三方的隐私实践负责,建议您阅读其隐私政策。 + +*** + +## 8. 儿童隐私 + +本应用不面向 14 周岁以下的儿童。我们不会故意收集儿童的个人信息。如果您是 14 周岁以下儿童的监护人,且发现您的孩子向我们提供了个人信息,请联系我们,我们将采取措施删除相关信息。 + +*** + +## 9. 隐私政策更新 + +我们可能会不时更新本隐私政策。更新后的政策将在本应用内发布,并在政策顶部注明"最后更新"日期。重大变更时,我们将在应用内通过显著方式通知您。 + +建议您定期查阅本政策,以了解我们如何保护您的信息。继续使用本应用即表示您接受更新后的隐私政策。 + +*** + +## 10. 适用法律 + +本隐私政策的解释和执行适用中华人民共和国法律法规,包括但不限于: + +- 《中华人民共和国个人信息保护法》 +- 《中华人民共和国数据安全法》 +- 《中华人民共和国网络安全法》 +- 《信息安全技术 个人信息安全规范》(GB/T 35273) + +*** + +## 11. 联系我们 + +如果您对本隐私政策有任何疑问、意见或建议,请通过以下方式联系我们: + +- **GitHub 仓库**: +- **问题反馈**: + +我们将在收到您的请求后 30 日内予以答复。 + +*** + +## 12. 条款可分割性 + +如果本隐私政策的任何条款被有管辖权的法院或监管机构认定为无效或不可执行,该条款应在最小必要范围内进行修改以使其有效和可执行,或如果无法修改,则予以删除。本政策的其余条款将继续有效。 + +*** + +**本隐私政策最终解释权归灵方软件Lincube所有。** diff --git a/LanMountainDesktop/DesktopEditing/DesktopEditCommitMath.cs b/LanMountainDesktop/DesktopEditing/DesktopEditCommitMath.cs new file mode 100644 index 0000000..2a35b7e --- /dev/null +++ b/LanMountainDesktop/DesktopEditing/DesktopEditCommitMath.cs @@ -0,0 +1,9 @@ +namespace LanMountainDesktop.DesktopEditing; + +internal static class DesktopEditCommitMath +{ + public static bool IsPendingCommitValid(bool isPending, int scheduledVersion, int currentVersion) + { + return isPending && scheduledVersion == currentVersion; + } +} diff --git a/LanMountainDesktop/DesktopEditing/DesktopEditGhostView.cs b/LanMountainDesktop/DesktopEditing/DesktopEditGhostView.cs new file mode 100644 index 0000000..fd5a510 --- /dev/null +++ b/LanMountainDesktop/DesktopEditing/DesktopEditGhostView.cs @@ -0,0 +1,241 @@ +using System; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace LanMountainDesktop.DesktopEditing; + +internal sealed class DesktopEditGhostView : Border +{ + private static readonly TimeSpan FastDuration = TimeSpan.FromMilliseconds(120); + private static readonly Easing StandardEasing = new CubicEaseOut(); + + private readonly Border _accentDot; + private readonly TextBlock _titleTextBlock; + private readonly TextBlock _detailTextBlock; + private readonly Border _badgeBorder; + private readonly TextBlock _badgeTextBlock; + private readonly ScaleTransform _scaleTransform = new(1, 1); + + private readonly SolidColorBrush _normalBackgroundBrush = new(Color.Parse("#F11B2430")); + private readonly SolidColorBrush _normalBorderBrush = new(Color.Parse("#4D8AA3C1")); + private readonly SolidColorBrush _normalAccentBrush = new(Color.Parse("#FF4F8EF7")); + private readonly SolidColorBrush _normalTextBrush = new(Color.Parse("#FFF5F7FA")); + private readonly SolidColorBrush _normalMutedTextBrush = new(Color.Parse("#BDE2E8F0")); + private readonly SolidColorBrush _normalBadgeBackgroundBrush = new(Color.Parse("#245E86D6")); + private readonly SolidColorBrush _normalBadgeBorderBrush = new(Color.Parse("#557EA7E6")); + private readonly SolidColorBrush _invalidBackgroundBrush = new(Color.Parse("#F01B1022")); + private readonly SolidColorBrush _invalidBorderBrush = new(Color.Parse("#FFE25555")); + private readonly SolidColorBrush _invalidAccentBrush = new(Color.Parse("#FFFF6B6B")); + private readonly SolidColorBrush _invalidBadgeBackgroundBrush = new(Color.Parse("#33FF4D4D")); + private readonly SolidColorBrush _invalidBadgeBorderBrush = new(Color.Parse("#88FF7676")); + + public DesktopEditGhostView() + { + HorizontalAlignment = HorizontalAlignment.Stretch; + VerticalAlignment = VerticalAlignment.Stretch; + Padding = new Thickness(14); + Background = _normalBackgroundBrush; + BorderBrush = _normalBorderBrush; + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(22); + ClipToBounds = true; + RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative); + RenderTransform = _scaleTransform; + Transitions = new Transitions + { + new DoubleTransition + { + Property = Visual.OpacityProperty, + Duration = FastDuration, + Easing = StandardEasing + } + }; + _scaleTransform.Transitions = new Transitions + { + new DoubleTransition + { + Property = ScaleTransform.ScaleXProperty, + Duration = FastDuration, + Easing = StandardEasing + }, + new DoubleTransition + { + Property = ScaleTransform.ScaleYProperty, + Duration = FastDuration, + Easing = StandardEasing + } + }; + + _accentDot = new Border + { + Width = 10, + Height = 10, + CornerRadius = new CornerRadius(999), + Background = _normalAccentBrush, + BorderThickness = new Thickness(0), + VerticalAlignment = VerticalAlignment.Center + }; + + _titleTextBlock = new TextBlock + { + Foreground = _normalTextBrush, + FontWeight = FontWeight.SemiBold, + TextTrimming = TextTrimming.CharacterEllipsis, + TextWrapping = TextWrapping.NoWrap, + MaxLines = 1 + }; + + _detailTextBlock = new TextBlock + { + Foreground = _normalMutedTextBrush, + TextTrimming = TextTrimming.CharacterEllipsis, + TextWrapping = TextWrapping.NoWrap, + MaxLines = 1 + }; + + _badgeTextBlock = new TextBlock + { + Foreground = _normalTextBrush, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + TextWrapping = TextWrapping.NoWrap, + MaxLines = 1 + }; + + _badgeBorder = new Border + { + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top, + Padding = new Thickness(9, 4), + CornerRadius = new CornerRadius(999), + Background = _normalBadgeBackgroundBrush, + BorderBrush = _normalBadgeBorderBrush, + BorderThickness = new Thickness(1), + Child = _badgeTextBlock + }; + + var headerPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + Children = + { + _accentDot, + _titleTextBlock + } + }; + + var contentPanel = new StackPanel + { + Spacing = 6, + Children = + { + headerPanel, + _detailTextBlock + } + }; + + var rootGrid = new Grid + { + RowDefinitions = new RowDefinitions + { + new RowDefinition(GridLength.Auto), + new RowDefinition(GridLength.Auto) + }, + RowSpacing = 8 + }; + rootGrid.Children.Add(contentPanel); + rootGrid.Children.Add(_badgeBorder); + Grid.SetRow(contentPanel, 0); + Grid.SetRow(_badgeBorder, 1); + _badgeBorder.Margin = new Thickness(0, 2, 0, 0); + + Child = rootGrid; + + UpdatePreviewMetrics(180, 120); + UpdateContent(null, null, null); + } + + public void UpdateContent(string? title, string? detail, string? badgeText) + { + _titleTextBlock.Text = string.IsNullOrWhiteSpace(title) ? "Component" : title; + _detailTextBlock.Text = string.IsNullOrWhiteSpace(detail) ? string.Empty : detail; + _detailTextBlock.IsVisible = !string.IsNullOrWhiteSpace(detail); + _badgeTextBlock.Text = string.IsNullOrWhiteSpace(badgeText) ? string.Empty : badgeText; + _badgeBorder.IsVisible = !string.IsNullOrWhiteSpace(badgeText); + } + + public void UpdatePreviewMetrics(double width, double height) + { + var normalizedWidth = Math.Max(1, width); + var normalizedHeight = Math.Max(1, height); + var minSide = Math.Max(1, Math.Min(normalizedWidth, normalizedHeight)); + + CornerRadius = new CornerRadius(Math.Clamp(minSide * 0.16, 16, 28)); + Padding = new Thickness( + Math.Clamp(minSide * 0.10, 10, 18), + Math.Clamp(minSide * 0.10, 10, 18), + Math.Clamp(minSide * 0.10, 10, 18), + Math.Clamp(minSide * 0.09, 10, 16)); + + var titleFontSize = Math.Clamp(minSide * 0.12, 12, 18); + var detailFontSize = Math.Clamp(minSide * 0.085, 10, 13); + var badgeFontSize = Math.Clamp(minSide * 0.08, 9, 12); + var dotSize = Math.Clamp(minSide * 0.07, 8, 12); + var badgeHorizontalPadding = Math.Clamp(minSide * 0.07, 8, 14); + var badgeVerticalPadding = Math.Clamp(minSide * 0.035, 3, 6); + + _accentDot.Width = dotSize; + _accentDot.Height = dotSize; + _titleTextBlock.FontSize = titleFontSize; + _detailTextBlock.FontSize = detailFontSize; + _badgeTextBlock.FontSize = badgeFontSize; + _badgeBorder.Padding = new Thickness(badgeHorizontalPadding, badgeVerticalPadding); + } + + public void SetInvalid(bool isInvalid) + { + if (isInvalid) + { + Background = _invalidBackgroundBrush; + BorderBrush = _invalidBorderBrush; + _accentDot.Background = _invalidAccentBrush; + _badgeBorder.Background = _invalidBadgeBackgroundBrush; + _badgeBorder.BorderBrush = _invalidBadgeBorderBrush; + _titleTextBlock.Foreground = _invalidBorderBrush; + _detailTextBlock.Foreground = _invalidBorderBrush; + _badgeTextBlock.Foreground = _invalidBorderBrush; + Opacity = 0.9; + return; + } + + Background = _normalBackgroundBrush; + BorderBrush = _normalBorderBrush; + _accentDot.Background = _normalAccentBrush; + _badgeBorder.Background = _normalBadgeBackgroundBrush; + _badgeBorder.BorderBrush = _normalBadgeBorderBrush; + _titleTextBlock.Foreground = _normalTextBrush; + _detailTextBlock.Foreground = _normalMutedTextBrush; + _badgeTextBlock.Foreground = _normalTextBrush; + Opacity = 1.0; + } + + public void SetRestingScale(double scale) + { + var clampedScale = Math.Clamp(scale, 0.85, 1.12); + _scaleTransform.ScaleX = clampedScale; + _scaleTransform.ScaleY = clampedScale; + } + + public void AnimateToScale(double scale) + { + var clampedScale = Math.Clamp(scale, 0.85, 1.12); + _scaleTransform.ScaleX = clampedScale; + _scaleTransform.ScaleY = clampedScale; + } +} diff --git a/LanMountainDesktop/DesktopEditing/DesktopEditOverlayPresenter.cs b/LanMountainDesktop/DesktopEditing/DesktopEditOverlayPresenter.cs new file mode 100644 index 0000000..5b68d91 --- /dev/null +++ b/LanMountainDesktop/DesktopEditing/DesktopEditOverlayPresenter.cs @@ -0,0 +1,279 @@ +using System; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using LanMountainDesktop.Theme; + +namespace LanMountainDesktop.DesktopEditing; + +internal sealed class DesktopEditOverlayPresenter +{ + private static readonly TimeSpan FastDuration = FluttermotionToken.Fast; + private static readonly Easing StandardEasing = new CubicEaseOut(); + + private readonly Canvas _root; + private readonly DesktopEditGhostView _ghostView; + private readonly Border _candidateOutline; + private readonly ScaleTransform _candidateScale = new(1, 1); + + private Rect? _previewRect; + private Rect? _candidateRect; + private bool _isInvalid; + private bool _isVisible; + private int _dismissVersion; + + private readonly SolidColorBrush _candidateBrush = new(Color.Parse("#FF4F8EF7")); + private readonly SolidColorBrush _candidateInvalidBrush = new(Color.Parse("#FFFF6B6B")); + private readonly SolidColorBrush _candidateFillBrush = new(Color.Parse("#224F8EF7")); + private readonly SolidColorBrush _candidateInvalidFillBrush = new(Color.Parse("#22FF6B6B")); + + public DesktopEditOverlayPresenter() + { + _ghostView = new DesktopEditGhostView + { + IsHitTestVisible = false, + Opacity = 1 + }; + + _candidateOutline = new Border + { + IsHitTestVisible = false, + Background = _candidateFillBrush, + BorderBrush = _candidateBrush, + BorderThickness = new Thickness(2), + CornerRadius = new CornerRadius(22), + Opacity = 0, + RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative), + RenderTransform = _candidateScale, + Transitions = new Transitions + { + new DoubleTransition + { + Property = Visual.OpacityProperty, + Duration = FastDuration, + Easing = StandardEasing + } + } + }; + _candidateScale.Transitions = new Transitions + { + new DoubleTransition + { + Property = ScaleTransform.ScaleXProperty, + Duration = FastDuration, + Easing = StandardEasing + }, + new DoubleTransition + { + Property = ScaleTransform.ScaleYProperty, + Duration = FastDuration, + Easing = StandardEasing + } + }; + + _candidateOutline.SetValue(Panel.ZIndexProperty, 0); + _ghostView.SetValue(Panel.ZIndexProperty, 1); + + _root = new Canvas + { + IsHitTestVisible = false, + ClipToBounds = false, + Opacity = 0, + IsVisible = false, + Children = + { + _candidateOutline, + _ghostView + } + }; + + _root.Transitions = new Transitions + { + new DoubleTransition + { + Property = Visual.OpacityProperty, + Duration = FastDuration, + Easing = StandardEasing + } + }; + } + + public Control Root => _root; + + public void SetViewportSize(Size size) + { + _root.Width = Math.Max(1, size.Width); + _root.Height = Math.Max(1, size.Height); + } + + public void SetPreviewRect(Rect rect) + { + _previewRect = Normalize(rect); + ApplyPreviewRect(); + } + + public void SetCandidateRect(Rect? rect) + { + _candidateRect = rect is null ? null : Normalize(rect.Value); + ApplyCandidateRect(); + } + + public void UpdateGhostContent(string? title, string? detail = null, string? badge = null) + { + _ghostView.UpdateContent(title, detail, badge); + } + + public void SetInvalid(bool isInvalid) + { + _isInvalid = isInvalid; + _ghostView.SetInvalid(isInvalid); + UpdateCandidateAppearance(); + } + + public void Show() + { + _dismissVersion++; + _isVisible = true; + _root.IsVisible = true; + _root.Opacity = 0; + _ghostView.Opacity = 0; + _ghostView.SetRestingScale(0.96); + _candidateOutline.Opacity = 0; + _candidateScale.ScaleX = 0.96; + _candidateScale.ScaleY = 0.96; + + Dispatcher.UIThread.Post(() => + { + if (!_isVisible) + { + return; + } + + _root.Opacity = 1; + _ghostView.Opacity = 1; + _ghostView.SetRestingScale(1); + if (_candidateRect.HasValue) + { + _candidateOutline.Opacity = 1; + _candidateScale.ScaleX = 1; + _candidateScale.ScaleY = 1; + } + }, DispatcherPriority.Background); + } + + public void Hide() + { + _dismissVersion++; + _isVisible = false; + _root.Opacity = 0; + _ghostView.Opacity = 0; + _candidateOutline.Opacity = 0; + _candidateScale.ScaleX = 0.96; + _candidateScale.ScaleY = 0.96; + _ghostView.SetRestingScale(0.96); + _root.IsVisible = false; + } + + public void Commit() + { + BeginDismiss(isCancel: false); + } + + public void Cancel() + { + BeginDismiss(isCancel: true); + } + + private void BeginDismiss(bool isCancel) + { + if (!_isVisible) + { + return; + } + + var version = ++_dismissVersion; + _isVisible = false; + _candidateOutline.Opacity = 0; + _ghostView.Opacity = 0; + _root.Opacity = 0; + + var targetScale = isCancel ? 0.96 : 1.04; + _ghostView.AnimateToScale(targetScale); + _candidateScale.ScaleX = targetScale; + _candidateScale.ScaleY = targetScale; + + DispatcherTimer.RunOnce( + () => + { + if (version != _dismissVersion) + { + return; + } + + _root.IsVisible = false; + }, + FastDuration); + } + + private void ApplyPreviewRect() + { + if (!_previewRect.HasValue) + { + return; + } + + var rect = _previewRect.Value; + _ghostView.Width = Math.Max(1, rect.Width); + _ghostView.Height = Math.Max(1, rect.Height); + Canvas.SetLeft(_ghostView, rect.X); + Canvas.SetTop(_ghostView, rect.Y); + _ghostView.UpdatePreviewMetrics(rect.Width, rect.Height); + } + + private void ApplyCandidateRect() + { + if (!_candidateRect.HasValue) + { + _candidateOutline.IsVisible = false; + _candidateOutline.Opacity = 0; + return; + } + + var rect = _candidateRect.Value; + _candidateOutline.IsVisible = true; + _candidateOutline.Width = Math.Max(1, rect.Width); + _candidateOutline.Height = Math.Max(1, rect.Height); + Canvas.SetLeft(_candidateOutline, rect.X); + Canvas.SetTop(_candidateOutline, rect.Y); + + var cornerRadius = Math.Clamp(Math.Min(rect.Width, rect.Height) * 0.12, 14, 28); + _candidateOutline.CornerRadius = new CornerRadius(cornerRadius); + _candidateOutline.BorderBrush = _isInvalid ? _candidateInvalidBrush : _candidateBrush; + _candidateOutline.Background = _isInvalid ? _candidateInvalidFillBrush : _candidateFillBrush; + _candidateOutline.Opacity = _isVisible ? 1 : 0; + _candidateScale.ScaleX = _isVisible ? 1 : 0.96; + _candidateScale.ScaleY = _isVisible ? 1 : 0.96; + UpdateCandidateAppearance(); + } + + private void UpdateCandidateAppearance() + { + if (!_candidateRect.HasValue) + { + return; + } + + _candidateOutline.BorderBrush = _isInvalid ? _candidateInvalidBrush : _candidateBrush; + _candidateOutline.Background = _isInvalid ? _candidateInvalidFillBrush : _candidateFillBrush; + } + + private static Rect Normalize(Rect rect) + { + var width = Math.Max(1, rect.Width); + var height = Math.Max(1, rect.Height); + return new Rect(rect.X, rect.Y, width, height); + } +} diff --git a/LanMountainDesktop/DesktopEditing/DesktopEditSession.cs b/LanMountainDesktop/DesktopEditing/DesktopEditSession.cs new file mode 100644 index 0000000..c86d5c0 --- /dev/null +++ b/LanMountainDesktop/DesktopEditing/DesktopEditSession.cs @@ -0,0 +1,205 @@ +using System; +using Avalonia; + +namespace LanMountainDesktop.DesktopEditing; + +internal enum DesktopEditSessionMode +{ + None = 0, + PendingNew, + DraggingNew, + DraggingExisting, + ResizingExisting +} + +internal readonly record struct DesktopEditSession +{ + public DesktopEditSessionMode Mode { get; init; } + public string? ComponentId { get; init; } + public string? PlacementId { get; init; } + public int PageIndex { get; init; } + public int WidthCells { get; init; } + public int HeightCells { get; init; } + public Point StartPointerInViewport { get; init; } + public Point CurrentPointerInViewport { get; init; } + public Point PointerOffsetInViewport { get; init; } + public Rect? ComponentLibraryBounds { get; init; } + public int TargetRow { get; init; } + public int TargetColumn { get; init; } + + public bool IsActive => Mode != DesktopEditSessionMode.None; + public bool IsPendingNew => Mode == DesktopEditSessionMode.PendingNew; + public bool IsDraggingNew => Mode == DesktopEditSessionMode.DraggingNew; + public bool IsDraggingExisting => Mode == DesktopEditSessionMode.DraggingExisting; + public bool IsResizingExisting => Mode == DesktopEditSessionMode.ResizingExisting; + public bool HasTargetCell => TargetRow >= 0 && TargetColumn >= 0; + + public double PointerTravelDistance => DesktopPlacementMath.Distance(StartPointerInViewport, CurrentPointerInViewport); + + public bool HasExceededThreshold(double threshold) + { + return DesktopPlacementMath.HasExceededThreshold(StartPointerInViewport, CurrentPointerInViewport, threshold); + } + + public bool IsPointerInsideComponentLibrary() + { + return DesktopPlacementMath.IsOccludedByComponentLibrary(CurrentPointerInViewport, ComponentLibraryBounds); + } + + public bool IsPreviewOccludedByComponentLibrary(Rect previewRect) + { + return DesktopPlacementMath.IsOccludedByComponentLibrary(previewRect, ComponentLibraryBounds); + } + + public bool CanCommit => IsActive && HasTargetCell; + + public Rect GetPreviewRect(DesktopGridGeometry grid) + { + if (HasTargetCell) + { + return DesktopPlacementMath.GetCellRect( + grid, + TargetColumn, + TargetRow, + Math.Max(1, WidthCells), + Math.Max(1, HeightCells)); + } + + var freePreviewOrigin = DesktopPlacementMath.Subtract(CurrentPointerInViewport, PointerOffsetInViewport); + return new Rect( + freePreviewOrigin, + new Size( + Math.Max(1, WidthCells) * grid.CellSize + Math.Max(0, Math.Max(1, WidthCells) - 1) * grid.CellGap, + Math.Max(1, HeightCells) * grid.CellSize + Math.Max(0, Math.Max(1, HeightCells) - 1) * grid.CellGap)); + } + + public DesktopEditSession WithCurrentPointer(Point pointerInViewport) + { + return this with { CurrentPointerInViewport = pointerInViewport }; + } + + public DesktopEditSession WithComponentLibraryBounds(Rect? componentLibraryBounds) + { + return this with { ComponentLibraryBounds = componentLibraryBounds }; + } + + public DesktopEditSession WithTargetCell(int row, int column) + { + return this with { TargetRow = row, TargetColumn = column }; + } + + public DesktopEditSession PromoteToDraggingNew() + { + return this with { Mode = DesktopEditSessionMode.DraggingNew }; + } + + public DesktopEditSession PromoteToDraggingExisting() + { + return this with { Mode = DesktopEditSessionMode.DraggingExisting }; + } + + public DesktopEditSession PromoteToResizingExisting() + { + return this with { Mode = DesktopEditSessionMode.ResizingExisting }; + } + + public static DesktopEditSession CreatePendingNew( + string componentId, + int pageIndex, + int widthCells, + int heightCells, + Point startPointerInViewport, + Point pointerOffsetInViewport, + Rect? componentLibraryBounds) + { + return new DesktopEditSession + { + Mode = DesktopEditSessionMode.PendingNew, + ComponentId = componentId, + PageIndex = pageIndex, + WidthCells = Math.Max(1, widthCells), + HeightCells = Math.Max(1, heightCells), + StartPointerInViewport = startPointerInViewport, + CurrentPointerInViewport = startPointerInViewport, + PointerOffsetInViewport = pointerOffsetInViewport, + ComponentLibraryBounds = componentLibraryBounds, + TargetRow = -1, + TargetColumn = -1 + }; + } + + public static DesktopEditSession CreateDraggingNew( + string componentId, + int pageIndex, + int widthCells, + int heightCells, + Point startPointerInViewport, + Point pointerOffsetInViewport, + Rect? componentLibraryBounds) + { + return CreatePendingNew( + componentId, + pageIndex, + widthCells, + heightCells, + startPointerInViewport, + pointerOffsetInViewport, + componentLibraryBounds) with + { + Mode = DesktopEditSessionMode.DraggingNew + }; + } + + public static DesktopEditSession CreateDraggingExisting( + string componentId, + string placementId, + int pageIndex, + int widthCells, + int heightCells, + Point startPointerInViewport, + Point pointerOffsetInViewport, + Rect? componentLibraryBounds) + { + return new DesktopEditSession + { + Mode = DesktopEditSessionMode.DraggingExisting, + ComponentId = componentId, + PlacementId = placementId, + PageIndex = pageIndex, + WidthCells = Math.Max(1, widthCells), + HeightCells = Math.Max(1, heightCells), + StartPointerInViewport = startPointerInViewport, + CurrentPointerInViewport = startPointerInViewport, + PointerOffsetInViewport = pointerOffsetInViewport, + ComponentLibraryBounds = componentLibraryBounds, + TargetRow = -1, + TargetColumn = -1 + }; + } + + public static DesktopEditSession CreateResizingExisting( + string componentId, + string placementId, + int pageIndex, + int widthCells, + int heightCells, + Point startPointerInViewport, + Rect? componentLibraryBounds) + { + return new DesktopEditSession + { + Mode = DesktopEditSessionMode.ResizingExisting, + ComponentId = componentId, + PlacementId = placementId, + PageIndex = pageIndex, + WidthCells = Math.Max(1, widthCells), + HeightCells = Math.Max(1, heightCells), + StartPointerInViewport = startPointerInViewport, + CurrentPointerInViewport = startPointerInViewport, + PointerOffsetInViewport = default, + ComponentLibraryBounds = componentLibraryBounds, + TargetRow = -1, + TargetColumn = -1 + }; + } +} diff --git a/LanMountainDesktop/DesktopEditing/DesktopPlacementMath.cs b/LanMountainDesktop/DesktopEditing/DesktopPlacementMath.cs new file mode 100644 index 0000000..4f3f320 --- /dev/null +++ b/LanMountainDesktop/DesktopEditing/DesktopPlacementMath.cs @@ -0,0 +1,176 @@ +using System; +using Avalonia; + +namespace LanMountainDesktop.DesktopEditing; + +internal readonly record struct DesktopGridGeometry( + Point Origin, + double CellSize, + double CellGap, + int ColumnCount, + int RowCount) +{ + public double Pitch => CellSize + CellGap; + + public bool IsValid => + CellSize > 0 && + ColumnCount > 0 && + RowCount > 0 && + Pitch > 0; +} + +internal static class DesktopPlacementMath +{ + public static double ComputeDragStartThreshold(double cellSize) + { + return Math.Max(10d, Math.Max(0d, cellSize) * 0.18d); + } + + public static double Distance(Point start, Point end) + { + return Math.Sqrt(DistanceSquared(start, end)); + } + + public static double DistanceSquared(Point start, Point end) + { + var deltaX = end.X - start.X; + var deltaY = end.Y - start.Y; + return deltaX * deltaX + deltaY * deltaY; + } + + public static bool HasExceededThreshold(Point start, Point end, double threshold) + { + if (threshold <= 0) + { + return true; + } + + return DistanceSquared(start, end) >= threshold * threshold; + } + + public static Point Add(Point left, Point right) + { + return new Point(left.X + right.X, left.Y + right.Y); + } + + public static Point Subtract(Point left, Point right) + { + return new Point(left.X - right.X, left.Y - right.Y); + } + + public static bool ContainsPoint(Rect rect, Point point) + { + return rect.Contains(point); + } + + public static bool Intersects(Rect left, Rect right) + { + return left.Intersects(right); + } + + public static bool HasCellPositionChanged(int originalRow, int originalColumn, int targetRow, int targetColumn) + { + return originalRow != targetRow || originalColumn != targetColumn; + } + + public static bool HasCellSpanChanged(int originalWidthCells, int originalHeightCells, int targetWidthCells, int targetHeightCells) + { + return originalWidthCells != targetWidthCells || originalHeightCells != targetHeightCells; + } + + public static bool IsOccludedByComponentLibrary(Point point, Rect? componentLibraryBounds) + { + return componentLibraryBounds.HasValue && ContainsPoint(componentLibraryBounds.Value, point); + } + + public static bool IsOccludedByComponentLibrary(Rect previewRect, Rect? componentLibraryBounds) + { + return componentLibraryBounds.HasValue && Intersects(previewRect, componentLibraryBounds.Value); + } + + public static bool CanCommitPlacement(Rect placementRect, Rect? componentLibraryBounds) + { + return !IsOccludedByComponentLibrary(placementRect, componentLibraryBounds); + } + + public static Rect GetGridBounds(DesktopGridGeometry grid) + { + if (!grid.IsValid) + { + return default; + } + + var width = grid.ColumnCount * grid.CellSize + Math.Max(0, grid.ColumnCount - 1) * grid.CellGap; + var height = grid.RowCount * grid.CellSize + Math.Max(0, grid.RowCount - 1) * grid.CellGap; + return new Rect(grid.Origin, new Size(width, height)); + } + + public static Rect GetCellRect( + DesktopGridGeometry grid, + int column, + int row, + int widthCells = 1, + int heightCells = 1) + { + var safeWidthCells = Math.Max(1, widthCells); + var safeHeightCells = Math.Max(1, heightCells); + var safeColumn = Math.Max(0, column); + var safeRow = Math.Max(0, row); + var pitch = grid.Pitch; + var x = grid.Origin.X + safeColumn * pitch; + var y = grid.Origin.Y + safeRow * pitch; + var width = safeWidthCells * grid.CellSize + Math.Max(0, safeWidthCells - 1) * grid.CellGap; + var height = safeHeightCells * grid.CellSize + Math.Max(0, safeHeightCells - 1) * grid.CellGap; + return new Rect(x, y, width, height); + } + + public static Rect GetSnappedCellRect( + DesktopGridGeometry grid, + Point pointerInViewport, + Point pointerOffset, + int widthCells, + int heightCells) + { + return TryGetSnappedCell(grid, pointerInViewport, pointerOffset, widthCells, heightCells, out var column, out var row) + ? GetCellRect(grid, column, row, widthCells, heightCells) + : default; + } + + public static bool TryGetSnappedCell( + DesktopGridGeometry grid, + Point pointerInViewport, + Point pointerOffset, + int widthCells, + int heightCells, + out int column, + out int row) + { + column = 0; + row = 0; + + if (!grid.IsValid) + { + return false; + } + + var safeWidthCells = Math.Max(1, widthCells); + var safeHeightCells = Math.Max(1, heightCells); + var maxColumn = Math.Max(0, grid.ColumnCount - safeWidthCells); + var maxRow = Math.Max(0, grid.RowCount - safeHeightCells); + var pitch = grid.Pitch; + if (pitch <= 0) + { + return false; + } + + var previewOrigin = Subtract(pointerInViewport, pointerOffset); + var relativeX = previewOrigin.X - grid.Origin.X; + var relativeY = previewOrigin.Y - grid.Origin.Y; + + column = (int)Math.Floor(relativeX / pitch); + row = (int)Math.Floor(relativeY / pitch); + column = Math.Clamp(column, 0, maxColumn); + row = Math.Clamp(row, 0, maxRow); + return true; + } +} diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 3c676f2..846b9b5 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -276,6 +276,7 @@ "settings.region.language_label": "Language", "settings.region.language_zh": "Chinese", "settings.region.language_en": "English", + "settings.region.language_ja": "Japanese", "settings.region.timezone_header": "Time Zone", "settings.region.timezone_desc": "Select a time zone. Clock and calendar widgets will follow this zone.", "settings.region.applied_format": "Language switched to: {0}", diff --git a/LanMountainDesktop/Localization/ja-JP.json b/LanMountainDesktop/Localization/ja-JP.json new file mode 100644 index 0000000..783bea0 --- /dev/null +++ b/LanMountainDesktop/Localization/ja-JP.json @@ -0,0 +1,973 @@ +{ + "app.title": "LanMountainDesktop", + "tray.tooltip": "LanMountainDesktop", + "tray.menu.show_desktop": "デスクトップを開く", + "tray.menu.settings": "設定", + "tray.menu.component_library": "ウィジェットライブラリ", + "tray.menu.restart": "アプリを再起動", + "tray.menu.exit": "アプリを終了", + "button.back_to_windows": "Windowsに戻る", + "button.back_to_platform": "{0}に戻る", + "tooltip.back_to_windows": "Windowsに戻る", + "tooltip.back_to_platform": "{0}に戻る", + "platform.windows": "Windows", + "platform.linux": "Linux", + "platform.macos": "macOS", + "tooltip.open_settings": "設定", + "settings.title": "設定", + "settings.shell.title": "設定", + "settings.shell.subtitle": "LanMountainDesktop 独立設定モジュール", + "settings.shell.sidebar_hint": "カテゴリを選択して、アプリの動作、デスクトップレイアウト、外観を調整します。", + "settings.shell.footer_hint": "トレイから開く設定は、この独立設定モジュールで管理されます。", + "settings.back_to_desktop": "デスクトップに戻る", + "settings.nav_header": "設定", + "settings.nav.group_desktop": "デスクトップ", + "settings.nav.group_system": "システム", + "settings.nav.group_extensions": "拡張機能", + "settings.nav.wallpaper": "壁紙", + "settings.nav.grid": "コンポーネント", + "settings.nav.color": "カラー", + "settings.nav.status_bar": "ステータスバー", + "settings.nav.weather": "天気", + "settings.nav.region": "地域", + "settings.nav.update": "アップデート", + "settings.nav.privacy": "プライバシー", + "settings.nav.launcher": "アプリランチャー", + "settings.nav.plugins": "プラグイン", + "settings.nav.about": "について", + "settings.wallpaper.title": "壁紙", + "settings.wallpaper.description": "画像または動画を選択して、アプリウィンドウの壁紙としてすぐに適用します。", + "settings.wallpaper.current_label": "現在の壁紙", + "settings.wallpaper.type_label": "壁紙タイプ", + "settings.wallpaper.type.image": "画像", + "settings.wallpaper.type.solid_color": "単色", + "settings.wallpaper.type.system": "システム壁紙", + "settings.wallpaper.system.label": "システム壁紙", + "settings.wallpaper.system.unavailable": "システム壁紙を読み込めません", + "settings.wallpaper.refresh_interval": "更新間隔", + "settings.wallpaper.refresh_now": "今すぐ更新", + "settings.wallpaper.refresh.30s": "30秒", + "settings.wallpaper.refresh.1m": "1分", + "settings.wallpaper.refresh.5m": "5分", + "settings.wallpaper.refresh.10m": "10分", + "settings.wallpaper.refresh.15m": "15分", + "settings.wallpaper.refresh.30m": "30分", + "settings.wallpaper.refresh.1h": "1時間", + "settings.wallpaper.refresh.2h": "2時間", + "settings.wallpaper.refresh.4h": "4時間", + "settings.wallpaper.refresh.8h": "8時間", + "settings.wallpaper.refresh.12h": "12時間", + "settings.wallpaper.refresh.24h": "24時間", + "settings.wallpaper.color_label": "壁紙の色", + "settings.wallpaper.placement_label": "配置", + "settings.wallpaper.placement_desc": "画像がデスクトップにどのように表示されるかを調整します。", + "settings.wallpaper.pick_button": "ファイルを参照", + "settings.wallpaper.clear_button": "単色にリセット", + "settings.wallpaper.no_selection": "壁紙が選択されていません。", + "settings.wallpaper.storage_unavailable": "ストレージプロバイダが利用できません。", + "settings.wallpaper.import_failed": "壁紙ファイルのインポートに失敗しました。", + "settings.wallpaper.image_applied": "画像の壁紙が適用されました。", + "settings.wallpaper.video_applied": "動画の壁紙が適用されました。", + "settings.wallpaper.unsupported_file": "選択されたファイルタイプはサポートされていません。", + "settings.wallpaper.apply_failed_format": "壁紙の適用に失敗しました: {0}", + "settings.wallpaper.mode_format": "壁紙モード: {0}。", + "settings.wallpaper.video_mode": "動画の壁紙は自動フィルモードを使用します。", + "settings.wallpaper.cleared": "背景が単色にリセットされました。", + "settings.wallpaper.default_status": "現在の背景は単色を使用しています。", + "settings.wallpaper.saved_not_found": "保存された壁紙ファイルが見つかりません。単色の背景を使用しています。", + "settings.wallpaper.restored": "保存された設定から壁紙が復元されました。", + "settings.wallpaper.video_restored": "保存された設定から動画の壁紙が復元されました。", + "settings.wallpaper.restore_failed": "保存された壁紙の復元に失敗しました。単色の背景を使用しています。", + "settings.wallpaper.video_not_found": "動画の壁紙ファイルが見つかりません。", + "settings.wallpaper.video_player_unavailable": "動画プレーヤーが利用できません。", + "settings.wallpaper.video_play_failed_format": "動画の壁紙の再生に失敗しました: {0}", + "settings.grid.title": "グリッドレイアウト", + "settings.grid.description": "すべてのコンポーネントは少なくとも1つのセルを占有する必要があります(最小1x1)。", + "settings.grid.short_side_label": "短辺のセル数", + "settings.grid.spacing_label": "グリッドの間隔", + "settings.grid.spacing_relaxed": "ゆとりあり(iOS)", + "settings.grid.spacing_compact": "コンパクト(Android)", + "settings.grid.edge_inset_label": "画面の余白", + "settings.grid.edge_inset_px_format": "≈ {0:F1}px", + "settings.grid.apply_button": "適用", + "settings.grid.info_format": "グリッド: {0}列 x {1}行 | セル {2:F1}px (1:1)", + "settings.color.title": "カラー", + "settings.color.description": "昼夜モードを切り替え、アプリのアクセントカラーを選択します。", + "settings.color.day_night_label": "昼夜モード", + "settings.color.day_night_on": "夜", + "settings.color.day_night_off": "昼", + "settings.color.recommended_label": "おすすめの色", + "settings.color.system_monet_label": "システムMonetカラー", + "settings.color.refresh_button": "更新", + "settings.color.mode_night": "夜モードが有効", + "settings.color.mode_day": "昼モードが有効", + "settings.color.mode_status_format": "テーマモード: {0}。", + "settings.color.monet_refreshed": "Monetカラーが更新されました。", + "settings.color.theme_ready_format": "テーマカラーの準備完了: {0}。", + "settings.color.theme_applied_format": "{0}カラーが適用されました: {1}。", + "settings.color.theme_updated_wallpaper": "壁紙が更新されました。Monetカラーが更新されました。", + "settings.color.theme_updated_video": "動画の壁紙が更新されました。テーマカラーが更新されました。", + "settings.color.theme_cleared_wallpaper": "壁紙がクリアされました。Monetカラーが更新されました。", + "settings.status_bar.title": "ステータスバー", + "settings.status_bar.description": "上部のステータスバーに表示するコンポーネントを選択します。", + "settings.status_bar.clock_header": "時計コンポーネント", + "settings.status_bar.clock_description": "上部のステータスバーに時計を表示します。", + "settings.status_bar.clock_transparent_background_label": "透明な背景", + "settings.status_bar.clock_transparent_background_desc": "カプセルの背景を削除し、時計のテキストのみを保持します。", + "settings.status_bar.spacing_header": "コンポーネントの間隔", + "settings.status_bar.spacing_desc": "ステータスバーコンポーネント間の間隔を調整します。", + "settings.status_bar.spacing_mode_compact": "コンパクト", + "settings.status_bar.spacing_mode_relaxed": "ゆとりあり", + "settings.status_bar.spacing_mode_custom": "カスタム", + "settings.status_bar.spacing_custom_label": "カスタム間隔(%)", + "settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px", + "settings.privacy.title": "プライバシー", + "settings.privacy.description": "アプリの改善に役立つオプションの匿名アップロードを管理します。", + "settings.privacy.crash_upload_title": "匿名クラッシュデータのアップロード", + "settings.privacy.crash_upload_description": "アプリケーションの安定性向上にご協力ください。", + "settings.privacy.usage_upload_title": "匿名使用データのアップロード", + "settings.privacy.usage_upload_description": "アプリケーション機能の改善にご協力ください。", + "settings.privacy.device_id_title": "デバイスID", + "settings.privacy.device_id_description": "このデバイスの一意識別子。更新をクリックして再生成します。", + "settings.privacy.refresh_device_id": "更新", + "settings.privacy.policy_hint_prefix": "詳細については、", + "settings.privacy.view_policy": "プライバシーポリシーをご覧ください", + "settings.weather.title": "天気", + "settings.weather.description": "天気の場所、Xiaomi天気プレビュー、起動時の位置情報取得動作を設定します。", + "settings.weather.location_source_header": "位置情報ソース", + "settings.weather.location_source_desc": "天気ウィジェットが場所を解決する方法を選択します。", + "settings.weather.mode_city_search": "都市検索", + "settings.weather.mode_coordinates": "座標", + "settings.weather.auto_refresh": "起動時に位置情報を自動更新", + "settings.weather.city_search_header": "都市検索", + "settings.weather.city_search_desc": "都市を検索し、天気の場所を適用します。", + "settings.weather.search_placeholder": "例: 東京", + "settings.weather.search_button": "検索", + "settings.weather.apply_city_button": "都市を適用", + "settings.weather.search_hint": "都市名で検索し、場所を適用します。", + "settings.weather.search_required": "都市のキーワードを入力してください。", + "settings.weather.search_no_results": "場所が見つかりませんでした。", + "settings.weather.search_failed_format": "検索に失敗しました: {0}", + "settings.weather.search_result_count_format": "{0}件の場所が見つかりました。", + "settings.weather.search_select_required": "検索結果から場所を1つ選択してください。", + "settings.weather.search_applied_format": "場所が適用されました: {0}", + "settings.weather.coordinates_header": "座標", + "settings.weather.coordinates_desc": "緯度/経度とオプションのキー/名前を設定します。", + "settings.weather.latitude_label": "緯度", + "settings.weather.longitude_label": "経度", + "settings.weather.location_key_placeholder": "場所キー(オプション)", + "settings.weather.location_name_placeholder": "表示名(オプション)", + "settings.weather.apply_coordinates_button": "座標を適用", + "settings.weather.coordinates_saved_format": "座標が保存されました: {0:F4}, {1:F4}", + "settings.weather.coordinates_default_name_format": "座標 {0:F4}, {1:F4}", + "settings.weather.location_services_header": "位置情報サービス", + "settings.weather.location_services_desc": "現在のWindowsの場所を使用し、起動時に自動的に更新するかどうかを決定します。", + "settings.weather.use_current_location": "現在地を使用", + "settings.weather.location_unsupported": "現在のプラットフォームは現在地の取得をサポートしていません。", + "settings.weather.location_ready": "現在のWindowsの場所を使用できます。", + "settings.weather.location_refreshing": "現在地を取得中...", + "settings.weather.location_refresh_success_format": "現在地が適用されました: {0}", + "settings.weather.location_refresh_failed_format": "現在地の取得に失敗しました: {0}", + "settings.weather.preview_header": "接続テスト", + "settings.weather.preview_desc": "テストリクエストを送信して現在の設定を確認します。", + "settings.weather.preview_button": "テスト取得", + "settings.weather.preview_section": "天気プレビュー", + "settings.weather.settings_section": "設定", + "settings.weather.preview_panel_header": "天気プレビュー", + "settings.weather.preview_panel_desc": "現在の天気サービスの状態を更新して確認します。", + "settings.weather.refresh_button": "更新", + "settings.weather.preview_updated_format": "{0}に更新", + "settings.weather.preview_hint": "テスト取得を使用して天気の設定を確認します。", + "settings.weather.preview_missing_location": "テストする前に天気の場所を適用してください。", + "settings.weather.preview_success_format": "テスト成功: {0} · {1} · {2}", + "settings.weather.preview_failed_format": "テスト取得に失敗しました: {0}", + "settings.weather.preview_unknown": "不明", + "settings.weather.alert_filter_header": "除外するアラート", + "settings.weather.alert_filter_desc": "これらの単語を含むアラートは表示されません。1行に1つのルール。", + "settings.weather.alert_filter_placeholder": "1行に1つのキーワード", + "settings.weather.icon_style_header": "天気アイコンスタイル", + "settings.weather.icon_style_desc": "天気シンボルのFluentアイコンスタイルを選択します。", + "settings.weather.icon_style_fluent_regular": "Fluent Regular", + "settings.weather.icon_style_fluent_filled": "Fluent Filled", + "settings.weather.no_tls_header": "TLSなしの天気リクエスト", + "settings.weather.no_tls_desc": "推奨されません。互換性のないネットワーク環境でのみ有効にしてください。", + "settings.weather.status_city_empty": "都市の場所が設定されていません。", + "settings.weather.status_city_format": "モード: {0} | {1} | キー: {2}", + "settings.weather.status_coordinates_format": "モード: {0} | 緯度 {1:F4}, 経度 {2:F4} | キー: {3}", + "settings.weather.city_selection_label": "都市選択", + "settings.weather.coordinates_selection_label": "座標の場所", + "settings.weather.location_city_summary_desc": "天気の照会に使用される現在の都市を選択します。", + "settings.weather.location_coordinates_summary_desc": "天気の照会に使用される緯度/経度とオプションの場所名を設定します。", + "settings.weather.location_not_selected": "場所が選択されていません", + "settings.weather.alert_list_label": "除外リスト", + "settings.weather.alert_list_desc": "1行に1つの除外ルール。", + "settings.weather.no_tls_toggle": "非TLSリクエストのフォールバックを許可", + "settings.weather.footer_hint": "デスクトップ天気ウィジェットは、ここで設定された場所とアラート除外設定を再利用します。", + "settings.weather.location_header": "天気の場所", + "settings.weather.location_desc": "天気ウィジェットで使用する場所を設定します。", + "settings.weather.location_placeholder": "例: 東京", + "settings.weather.location_apply": "保存", + "settings.weather.location_empty": "天気の場所が設定されていません。", + "settings.weather.location_required": "天気の場所は空にできません。", + "settings.weather.location_current_format": "現在の天気の場所: {0}", + "settings.weather.location_saved_format": "天気の場所が保存されました: {0}", + "weather.widget.location_not_configured": "天気の場所が設定されていません", + "weather.widget.configure_hint": "設定 > 天気を開いて設定", + "weather.widget.loading": "読み込み中...", + "weather.widget.fetch_failed": "天気の取得に失敗しました", + "weather.widget.retrying": "自動的に再試行中", + "weather.widget.location_unknown": "不明な場所", + "weather.widget.condition_clear": "晴れ", + "weather.widget.condition_cloudy": "曇り", + "weather.widget.condition_rain": "雨", + "weather.widget.condition_storm": "雷雨", + "weather.widget.condition_snow": "雪", + "weather.widget.condition_fog": "霧", + "weather.widget.condition_unknown": "不明", + "weather.widget.range_unknown": "-- / --", + "weather.widget.range_format": "{0} / {1}", + "schedule.widget.no_source": "ClassIslandのスケジュールデータが見つかりません", + "schedule.widget.no_class_today": "今日の授業はありません", + "schedule.widget.layout_missing": "スケジュールの時間レイアウトがありません", + "schedule.widget.subject_fallback": "無題の授業", + "schedule.widget.detail_fallback": "詳細なし", + "schedule.settings.title": "スケジュールのインポート", + "schedule.settings.desc": "ClassIsland CSESスケジュールをインポートし、有効にするものを選択します。", + "schedule.settings.add": "スケジュールを追加", + "schedule.settings.empty": "インポートされたスケジュールはありません", + "schedule.settings.unnamed": "無題のスケジュール", + "schedule.settings.delete": "削除", + "schedule.settings.picker_title": "ClassIslandスケジュールファイルを選択", + "schedule.settings.picker_file_type.all": "ClassIslandスケジュールファイル", + "schedule.settings.picker_file_type.json": "ClassIslandプロファイル(JSON)", + "schedule.settings.picker_file_type.cses": "CSESスケジュール(YAML)", + "schedule.settings.semester.title": "学期設定", + "schedule.settings.semester.start_date": "学期開始日", + "schedule.settings.semester.week_cycle": "週サイクル", + "schedule.settings.semester.week_cycle_desc": "複数週スケジュールの週ローテーションサイクルを設定します(例: 奇数週/偶数週の場合は2)。", + "schedule.settings.semester.week_cycle_format": "{0}週ローテーション", + "worldclock.settings.title": "世界時計の設定", + "worldclock.settings.desc": "4つの時計それぞれのタイムゾーンを選択します。", + "worldclock.settings.clock_1": "時計 1", + "worldclock.settings.clock_2": "時計 2", + "worldclock.settings.clock_3": "時計 3", + "worldclock.settings.clock_4": "時計 4", + "worldclock.settings.second_mode_label": "秒針", + "worldclock.widget.today": "今日", + "worldclock.widget.yesterday": "昨日", + "worldclock.widget.tomorrow": "明日", + "worldclock.widget.offset_same": "0時間", + "worldclock.widget.offset_ahead_hours": "{0}時間進む", + "worldclock.widget.offset_behind_hours": "{0}時間遅れる", + "worldclock.widget.offset_ahead_hm": "{0}時間{1}分進む", + "worldclock.widget.offset_behind_hm": "{0}時間{1}分遅れる", + "weather.widget.aqi_unknown": "AQI --", + "weather.widget.aqi_format": "AQI {0}", + "weather.widget.updated_format": "{0:HH:mm}に更新", + "weather.hourly.now": "現在", + "weather.hourly.sunset": "日没", + "weather.multiday.today": "今日", + "weather.multiday.tomorrow": "明日", + "weather.multiday.aqi_format": "空気質 {0}", + "weather.multiday.aqi_unknown": "空気質 --", + "settings.region.title": "地域", + "settings.region.description": "言語を選択し、設定と主要なUIにすぐに適用します。", + "settings.region.language_header": "言語", + "settings.region.language_label": "言語", + "settings.region.language_zh": "中国語", + "settings.region.language_en": "英語", + "settings.region.language_ja": "日本語", + "settings.region.timezone_header": "タイムゾーン", + "settings.region.timezone_desc": "タイムゾーンを選択します。時計とカレンダーウィジェットはこのゾーンに従います。", + "settings.region.applied_format": "言語が切り替わりました: {0}", + "settings.region.follow_system": "システムの既定に従う", + "settings.general.title": "一般", + "settings.general.description": "言語、タイムゾーン、ランタイムの動作を調整します。", + "settings.general.basic_header": "基本設定", + "settings.general.runtime_header": "ランタイム", + "settings.general.preview_header": "日時プレビュー", + "settings.general.preview_time_label": "時刻", + "settings.general.preview_date_label": "日付", + "settings.general.render_mode_restart_message": "レンダリングモードの変更にはアプリの再起動が必要です。", + "settings.appearance.title": "外観", + "settings.appearance.description": "テーマソース、システムマテリアル、ウィンドウクロームを調整します。", + "settings.appearance.theme_header": "テーマ", + "settings.color.enable_night_mode_toggle": "夜モードを有効にする", + "settings.color.use_system_chrome_toggle": "システムのウィンドウクロームを使用", + "settings.color.theme_color_label": "テーマのアクセントカラー", + "settings.appearance.theme_color_mode_label": "テーマカラーソース", + "settings.appearance.theme_color_mode.neutral": "デフォルトニュートラル", + "settings.appearance.theme_color_mode.user": "ユーザーテーマカラーMonet", + "settings.appearance.theme_color_mode.wallpaper": "壁紙Monet", + "settings.appearance.theme_color_mode_desc.neutral": "ライトモードとダークモードにデフォルトの白と黒のニュートラルサーフェスを使用します。", + "settings.appearance.theme_color_mode_desc.user": "選択したテーマカラーをシェル全体のMonetシードとして使用します。", + "settings.appearance.theme_color_mode_desc.wallpaper": "壁紙の色を使用します。アプリの壁紙が優先され、次にシステムの壁紙が使用されます。", + "settings.appearance.theme_color_preview.app": "現在、アプリの壁紙から抽出された色をプレビューしています。", + "settings.appearance.theme_color_preview.system": "現在、システムの壁紙から抽出された色をプレビューしています。", + "settings.appearance.theme_color_preview.fallback": "使用可能な壁紙が見つかりませんでした。アプリはフォールバックのアクセントを使用しています。", + "component.color_scheme.follow_system": "システムのカラースキームに従う", + "component.color_scheme.native": "コンポーネントのカスタムカラースキームを使用", + "settings.appearance.system_material.none": "なし", + "settings.appearance.system_material.mica": "Mica", + "settings.appearance.system_material.acrylic": "Acrylic", + "settings.appearance.system_material_desc.switchable": "選択したマテリアルをウィンドウ、Dock、ステータスバー、コンポーネントホストに適用します。", + "settings.appearance.system_material_desc.fixed": "現在のシステムは、ここにリストされているマテリアルモードのみを公開しています。", + "settings.appearance.restart_message": "テーマソースとシステムマテリアルの変更にはアプリの再起動が必要です。", + "settings.appearance.preview.primary": "プライマリ", + "settings.appearance.preview.secondary": "セカンダリ", + "settings.appearance.preview.tertiary": "ターシャリ", + "settings.appearance.preview.neutral": "ニュートラル", + "settings.appearance.preview.seed": "シード", + "settings.appearance.preview.neutral_light": "白", + "settings.appearance.preview.neutral_dark": "黒", + "settings.appearance.preview.apply_seed": "適用", + "settings.appearance.preview.wallpaper_candidates": "壁紙シード候補", + "settings.appearance.preview.wallpaper_current": "現在", + "settings.wallpaper.placement.fill": "フィル", + "settings.wallpaper.placement.fit": "フィット", + "settings.wallpaper.placement.stretch": "ストレッチ", + "settings.wallpaper.placement.center": "中央", + "settings.wallpaper.placement.tile": "タイル", + "settings.status_bar.clock_format_label": "時計の形式", + "settings.status_bar.clock_format.hm": "時:分", + "settings.status_bar.clock_format.hms": "時:分:秒", + "settings.components.title": "コンポーネント", + "settings.components.description": "コンポーネントのレイアウトとコーナーデザインを調整します。", + "settings.components.grid_header": "グリッド設定", + "settings.components.header": "グリッド設定", + "settings.components.short_side_label": "短辺のセル数", + "settings.components.edge_inset_label": "画面の余白", + "settings.components.spacing_label": "コンポーネントの間隔", + "settings.components.spacing_compact": "コンパクト", + "settings.components.spacing_relaxed": "ゆとりあり", + "settings.components.corner_radius.header": "コーナーデザイン", + "settings.components.corner_radius.label": "コンポーネントのコーナー半径", + "settings.components.corner_radius.description": "角張った端からカプセルのような形まで、共通のコーナー半径を調整し、内部のセーフエリアを拡張します。", + "settings.update.title": "アップデート", + "settings.update.current_version_label": "現在のバージョン", + "settings.update.latest_version_label": "最新リリース", + "settings.update.published_at_label": "公開日", + "settings.update.options_header": "アップデートオプション", + "settings.update.options_desc": "アップデートチェックとリリースチャンネルを設定します。", + "settings.update.auto_check_toggle": "起動時に自動的にアップデートを確認", + "settings.update.include_prerelease_toggle": "プレリリース版を含める", + "settings.update.channel_label": "アップデートチャンネル", + "settings.update.channel_stable": "安定版", + "settings.update.channel_preview": "プレビュー", + "settings.update.actions_header": "アップデートアクション", + "settings.update.actions_desc": "リリースを確認し、インストーラーをダウンロードし、アップデートを開始します。", + "settings.update.check_button": "アップデートを確認", + "settings.update.download_install_button": "ダウンロードしてインストール", + "settings.update.download_progress_idle": "ダウンロード進捗: -", + "settings.update.download_progress_format": "ダウンロード進捗: {0:F0}%", + "settings.update.status_ready": "アップデートを確認する準備ができました。", + "settings.update.status_channel_changed": "アップデートチャンネルが変更されました。再度確認してください。", + "settings.update.status_channel_changed_format": "アップデートチャンネルが{0}に切り替わりました。再度確認してください。", + "settings.update.status_windows_only": "自動インストーラーアップデートは現在Windowsでのみ利用可能です。", + "settings.update.status_checking": "GitHubリリースを確認中...", + "settings.update.status_check_failed_format": "アップデートの確認に失敗しました: {0}", + "settings.update.status_up_to_date": "最新バージョンを使用しています。", + "settings.update.status_asset_missing": "新しいリリースが利用可能ですが、互換性のあるインストーラーが見つかりませんでした。", + "settings.update.status_available_format": "新しいバージョン{0}が利用可能です。ダウンロードしてインストールをクリックしてください。", + "settings.update.status_downloading": "インストーラーをダウンロード中...", + "settings.update.status_download_failed_format": "ダウンロードに失敗しました: {0}", + "settings.update.status_launching_installer": "ダウンロード完了。インストーラーを起動中...", + "settings.update.status_installer_missing": "ダウンロード後にインストーラーファイルが見つかりませんでした。", + "settings.update.status_installer_started": "インストーラーが開始されました。アプリはアップデートのために終了します。", + "settings.update.status_elevation_cancelled": "管理者権限が付与されませんでした。アップデートはキャンセルされました。", + "settings.update.status_launch_failed_format": "インストーラーの起動に失敗しました: {0}", + "settings.about.title": "について", + "settings.about.version_format": "バージョン: {0}", + "settings.about.codename_format": "コードネーム: {0}", + "settings.about.font_format": "フォント: {0}", + "settings.about.startup_header": "Windowsのスタートアップ", + "settings.about.startup_desc": "Windowsへのサインイン時にアプリを自動的に起動します。", + "settings.about.startup_toggle": "Windowsサインイン時に起動", + "settings.about.render_mode_header": "アプリのレンダリングモード", + "settings.about.render_mode_desc": "レンダリングバックエンドを選択します。このオプションを変更した後、アプリを再起動します。サポートされていないモードはソフトウェアにフォールバックします。", + "settings.about.render_mode.default": "デフォルト", + "settings.about.render_mode.software": "ソフトウェア", + "settings.about.render_mode.angle_egl": "angleEgl", + "settings.about.render_mode.wgl": "WGL", + "settings.about.render_mode.vulkan": "Vulkan", + "settings.about.render_mode.unknown": "不明", + "settings.about.render_mode.current_label": "現在の実際のバックエンド", + "settings.about.render_mode.current_format": "現在のバックエンド: {0}", + "settings.about.render_mode.impl_format": "ランタイム実装: {0}", + "settings.about.render_mode.impl_unavailable": "ランタイム実装の詳細は利用できません。", + "settings.about.description": "アプリケーションの詳細。", + "settings.update.description": "リリースを確認し、アップデートチャンネルとダウンロードソースを選択し、アップデートのインストール方法を制御します。", + "settings.update.status_card_title": "アップデートステータス", + "settings.update.status_card_description": "アップデートを確認し、リリースの詳細を確認し、新しいバージョンが利用可能な場合はダウンロードまたはインストールを続行します。", + "settings.update.preferences_header": "アップデート設定", + "settings.update.preferences_description": "リリースチャンネル、インストーラーのダウンロードソース、インストール動作、ダウンロードの並列度を選択します。", + "settings.update.last_checked_label": "最終確認日時", + "settings.update.source_label": "ダウンロードソース", + "settings.update.source_github": "GitHub", + "settings.update.source_ghproxy": "gh-proxy", + "settings.update.source_github_desc": "GitHubからリリースアセットを直接ダウンロードします。", + "settings.update.source_ghproxy_desc": "GitHubリリースアセットをダウンロードする際にgh-proxyミラーを使用します。", + "settings.update.mode_label": "アップデートモード", + "settings.update.mode_manual": "手動アップデート", + "settings.update.mode_download_then_confirm": "サイレントダウンロード", + "settings.update.mode_silent_on_exit": "サイレントインストール", + "settings.update.mode_manual_desc": "アップデートの確認のみ。ダウンロードとインストールのタイミングを決定します。", + "settings.update.mode_download_then_confirm_desc": "バックグラウンドでアップデートをダウンロードし、インストール前に確認を求めます。", + "settings.update.mode_silent_on_exit_desc": "バックグラウンドでアップデートをダウンロードし、アプリの終了時にインストールします。", + "settings.update.channel_stable_desc": "安定ビルドは信頼性を重視し、ほとんどのユーザーにおすすめです。", + "settings.update.channel_preview_desc": "プレビュービルドは新しい機能が含まれる可能性がありますが、安定性が低い場合があります。", + "settings.update.download_threads_label": "ダウンロードスレッド", + "settings.update.download_threads_desc": "アプリケーションのアップデートパッケージの並列ダウンロードスレッド数を設定します。", + "settings.update.install_now_button": "今すぐインストール", + "settings.update.status_downloaded_confirm": "アップデートがダウンロードされました。確認してインストールのタイミングを選択してください。", + "settings.update.status_downloaded_exit": "アップデートがダウンロードされました。アプリの終了時にインストールされます。", + "settings.about.app_info_header": "アプリケーション情報", + "settings.about.update_header": "アップデート", + "settings.about.version_label": "バージョン", + "settings.about.codename_label": "コードネーム", + "settings.about.render_backend_label": "レンダーバックエンド", + "settings.about.render_backend_format": "レンダーバックエンド: {0}", + "settings.restart_dialog.title": "再起動が必要", + "settings.restart_dialog.render_mode_message": "レンダリングモードを「{0}」から「{1}」に切り替えるには、アプリを再起動します。今すぐ再起動しますか?", + "settings.restart_dialog.restart": "今すぐ再起動", + "settings.restart_dialog.later": "後で", + "settings.restart_dialog.cancel": "キャンセル", + "settings.restart_dock.title": "再起動が必要", + "settings.restart_dock.description": "一部の変更はアプリの再起動後に有効になります。", + "settings.restart_dock.button": "アプリを再起動", + "settings.footer": "LanMountainDesktop 設定", + "filepicker.title": "壁紙を選択", + "filepicker.image_files": "画像ファイル", + "filepicker.video_files": "動画ファイル", + "common.day": "昼", + "common.night": "夜", + "common.back": "戻る", + "common.close": "閉じる", + "common.unknown": "不明なエラー", + "common.recommended": "おすすめ", + "common.monet": "Monet", + "desktop.page_index_format": "デスクトップ {0}", + "launcher.title": "アプリランチャー", + "launcher.folder": "フォルダ", + "launcher.subtitle": "Windowsスタートメニューからのアプリとフォルダ", + "launcher.subtitle_linux": "Linuxデスクトップエントリから発見されたインストール済みアプリ", + "launcher.empty": "スタートメニューのエントリが見つかりません。", + "launcher.empty_linux": "Linuxデスクトップエントリが見つかりませんでした。", + "launcher.empty_folder": "このフォルダは空です。", + "launcher.folder_items_format": "{0}個のアプリ", + "launcher.context.hide_icon": "アイコンを非表示", + "launcher.action.hide": "非表示", + "settings.launcher.title": "アプリランチャー", + "settings.launcher.description": "アプリランチャーの非表示アプリとフォルダを管理します。", + "settings.launcher.hidden_header": "非表示アイテム", + "settings.launcher.hidden_desc": "非表示のランチャーエントリを確認し、再度表示します。", + "settings.launcher.hidden_hint": "デスクトップ編集モードで、ランチャーアイコンを選択して非表示をクリックします。非表示のエントリはここに表示されます。", + "settings.launcher.hidden_empty": "非表示アイテムはありません。", + "settings.launcher.hidden_summary_format": "{0}個の非表示アイテム", + "settings.launcher.hidden_type_folder": "フォルダ", + "settings.launcher.hidden_type_shortcut": "アプリ", + "settings.launcher.restore_button": "再表示", + "settings.plugins.title": "プラグイン", + "settings.plugins.runtime_header": "プラグインランタイム", + "settings.plugins.runtime_desc": "プラグインランタイムの状態とロード結果を確認します。", + "settings.plugins.runtime_hint": "このページには、インストールされたプラグインの発見ステータス、ロード結果、ランタイム診断が表示されます。", + "settings.plugins.runtime_status": "プラグインの発見が完了すると、プラグインランタイムのステータスがここに表示されます。", + "settings.plugins.description": "インストールされたプラグインを管理し、ランタイムの状態を確認します。", + "settings.plugins.initial_status": "プラグインの状態を更新して、最新のインストール済みプラグインを確認してください。", + "settings.plugins.refresh_button": "プラグインを更新", + "settings.plugins.refresh_success_installed_format": "{0}個のインストール済みプラグインをロードしました。", + "settings.plugins.refresh_success_format": "{0}個のインストール済みプラグインと{1}個のマーケットプレイスエントリをロードしました。", + "settings.plugins.refresh_failed": "プラグインマーケットインデックスのロードに失敗しました。", + "settings.plugins.marketplace_header": "マーケットプレイス", + "settings.plugins.marketplace_empty": "現在、マーケットプレイスのプラグインはありません。", + "settings.plugins.delete_button_short": "削除", + "settings.plugins.install_button_short": "インストール", + "settings.plugins.restart_required": "プラグインの変更は再起動後に有効になります。", + "settings.plugins.toggle_unchanged_format": "プラグイン「{0}」は変更されませんでした。", + "settings.plugins.delete_failed_name_format": "プラグイン「{0}」の削除に失敗しました。", + "settings.plugins.install_failed_name_format": "「{0}」のインストールに失敗しました。", + "settings.plugins.installed_header": "インストール済みプラグイン", + "settings.plugins.installed_desc": "インストール済みプラグインを確認し、ここで削除します。", + "settings.plugins.import_header": "パッケージからインストール", + "settings.plugins.import_desc": ".laappパッケージを開き、ローカルプラグインディレクトリにステージングします。", + "settings.plugins.restart_hint": "プラグインのインストールと削除の変更は、アプリの再起動後に有効になります。", + "settings.plugins.empty": "プラグインが見つかりません。", + "settings.plugins.runtime_unavailable": "プラグインランタイムは利用できません。", + "settings.plugins.summary_format": "{0}個のプラグインを検出; 有効 {1}; ロード済み {2}; 設定ページ {3}; ウィジェット {4}; 失敗 {5}。", + "settings.plugins.summary_item_format": "{0} v{1} | {2}", + "settings.plugins.state.enabled": "有効", + "settings.plugins.state.enabled_failed": "有効 / ロード失敗", + "settings.plugins.state.disabled": "無効", + "settings.plugins.state.loaded": "ロード済み", + "settings.plugins.state.load_failed": "ロード失敗", + "settings.plugins.toggle_on": "有効", + "settings.plugins.toggle_off": "無効", + "settings.plugins.toggle_result_format": "プラグイン「{0}」は次回起動時に{1}になりました。ページとウィジェットの変更を適用するには、アプリを再起動してください。", + "settings.plugins.toggle_state_enabled": "有効", + "settings.plugins.toggle_state_disabled": "無効", + "settings.plugins.toggle_failed_detail_format": "プラグイン「{0}」の更新に失敗しました: {1}", + "settings.plugins.install_button": ".laappパッケージを開く", + "settings.plugins.install_unavailable": "プラグインランタイムが利用できないため、.laappパッケージをインストールできません。", + "settings.plugins.install_hint_format": ".laappパッケージを開いて次にインストールします: {0}", + "settings.plugins.install_picker_title": "プラグインパッケージを選択", + "settings.plugins.install_file_type": ".laappプラグインパッケージ", + "settings.plugins.install_picker_unavailable": "ストレージプロバイダが利用できません。", + "settings.plugins.install_copy_failed": "選択した.laappパッケージのコピーに失敗しました。", + "settings.plugins.install_success_format": "プラグイン「{0}」がインストールされました。新しく追加された設定ページとウィジェットを適用するには、アプリを再起動してください。", + "settings.plugins.install_failed_format": "プラグインパッケージのインストールに失敗しました: {0}", + "settings.plugins.delete_button": "プラグインを削除", + "settings.plugins.delete_success_format": "プラグイン「{0}」は削除のためにステージングされました。削除を完了するには、アプリを再起動してください。", + "settings.plugins.delete_failed_format": "プラグインの削除に失敗しました: {0}", + "settings.plugins.delete_failed_detail_format": "プラグイン「{0}」の削除に失敗しました: {1}", + "settings.plugins.publisher_format": "パブリッシャー: {0}", + "settings.plugins.publisher_unknown": "不明なパブリッシャー", + "settings.plugins.source_package": ".laappパッケージ", + "settings.plugins.source_manifest": "ルーズマニフェスト", + "settings.plugins.subtitle_format": "{0} | {1} | {2}", + "settings.plugins.detail_format": "設定ページ: {0} | ウィジェット: {1}", + "settings.nav.plugin_market": "プラグインマーケット", + "settings.plugin_market.title": "プラグインマーケット", + "settings.plugin_market.subtitle": "公式LanAirAppソースからプラグインを参照し、インストールをステージングします。", + "settings.plugin_market.unavailable": "プラグインランタイムが利用できないため、公式マーケットを開けません。", + "settings.update.status_idle": "アップデートの確認はまだ実行されていません。", + "settings.update.status_preferences_saved": "アップデート設定が保存されました。", + "settings.update.status_check_failed": "アップデートの確認に失敗しました。", + "settings.update.status_available_summary_format": "アップデートあり: {0}(現在: {1})", + "settings.update.status_up_to_date_format": "最新版です({0})。", + "settings.window.drawer_default": "詳細", + "market.toolbar.search_placeholder": "プラグインを検索", + "market.toolbar.refresh": "更新", + "market.status.loading": "公式プラグインマーケットをロード中...", + "market.status.loaded_network_format": "公式ソースから{0}個のプラグインをロードしました。", + "market.status.loaded_cache_format": "公式ソースが利用できません。キャッシュから{0}個のプラグインをロードしました。理由: {1}", + "market.status.load_failed_format": "プラグインマーケットのロードに失敗しました: {0}", + "market.status.installing_format": "プラグイン「{0}」をダウンロードしてステージング中...", + "market.status.install_success_format": "プラグイン「{0}」がステージングされました。適用するにはアプリを再起動してください。", + "market.status.install_failed_format": "プラグインのインストールに失敗しました: {0}", + "market.status.host_incompatible_format": "このホストは古すぎます。バージョン{0}以降が必要です。", + "market.list.empty": "プラグインマーケットはまだロードされていません。", + "market.list.no_results": "現在の検索に一致するプラグインはありません。", + "market.card.subtitle_format": "{0} | v{1}", + "market.card.loaded": "ロード済み", + "market.card.pending_restart": "再起動が必要", + "market.detail.placeholder": "左側のプラグインを選択して詳細を確認します。", + "market.detail.author": "パブリッシャー", + "market.detail.version": "バージョン", + "market.detail.api_version": "APIバージョン", + "market.detail.min_host_version": "最小ホストバージョン", + "market.detail.installed_version": "インストール済みバージョン", + "market.detail.not_installed": "未インストール", + "market.detail.readme": "README", + "market.detail.plugin_information": "プラグイン情報", + "market.detail.author_subtitle_format": "{0}作成", + "market.detail.package_size": "パッケージサイズ", + "market.detail.published_at": "公開日", + "market.detail.updated_at": "更新日", + "market.detail.tags": "タグ", + "market.detail.project": "プロジェクト", + "market.detail.state": "インストール状態", + "market.detail.market_source": "マーケットソース", + "market.detail.homepage": "ホームページ", + "market.detail.repository": "リポジトリ", + "market.detail.release_notes": "リリースノート", + "market.detail.dependencies": "依存関係", + "market.detail.dependencies_empty": "このプラグインは共有コントラクトの依存関係を宣言していません。", + "market.detail.readme_loading": "READMEをロード中...", + "market.detail.readme_empty": "READMEは空です。", + "market.detail.readme_error_format": "READMEをロードできませんでした: {0}", + "market.detail.state.not_installed": "未インストール", + "market.detail.state.update_available": "アップデートあり", + "market.detail.state.installed": "インストール済み", + "market.detail.unknown": "不明", + "market.button.install": "インストール", + "market.button.update": "アップデート", + "market.button.installed": "インストール済み", + "market.button.installing": "インストール中...", + "market.button.restart": "再起動して適用", + "button.component_library": "デスクトップを編集", + "tooltip.component_library": "デスクトップを編集", + "component_library.title": "ウィジェット", + "component_library.empty": "スワイプしてカテゴリを選択し、タップして開き、ウィジェットをデスクトップにドラッグします。", + "component_library.drag_hint": "ドラッグして配置", + "component.delete": "削除", + "component.edit": "編集", + "component.editor.instance_scope": "変更はこのコンポーネントインスタンスにのみ適用されます。", + "component.editor.info_header": "コンポーネント情報", + "component.editor.id_label": "コンポーネントID", + "component.editor.placement_label": "配置ID", + "component.editor.scope_label": "スコープ", + "component.editor.scope_instance": "インスタンススコープのエディタ", + "component_category.clock": "時計", + "component_category.date": "カレンダー", + "component_category.weather": "天気", + "component_category.board": "ボード", + "component_category.media": "メディア", + "component_category.info": "情報", + "component_category.calculator": "計算機", + "component_category.study": "学習", + "component_category.file": "ファイル", + "component.date": "カレンダー", + "component.month_calendar": "月間カレンダー", + "component.lunar_calendar": "旧暦カレンダー", + "component.desktop_clock": "時計", + "component.weather_clock": "天気時計", + "component.world_clock": "世界時計", + "component.desktop_timer": "タイマー", + "component.desktop_weather": "天気", + "component.hourly_weather": "時間別天気", + "component.multiday_weather": "数日間天気", + "component.extended_weather": "拡張天気", + "component.class_schedule": "時間割", + "component.music_control": "音楽コントロール", + "component.audio_recorder": "レコーダー", + "component.daily_poetry": "今日の詩", + "component.daily_artwork": "今日のアート", + "component.daily_word": "今日の言葉", + "component.daily_word_2x2": "今日の言葉 2x2", + "component.cnr_daily_news": "CNRヘッドライン", + "component.ifeng_news": "iFengニュース", + "component.bilibili_hot_search": "Bilibiliトレンド", + "component.baidu_hot_search": "Baiduトレンド", + "component.stcn24_forum": "STCN 24", + "component.exchange_rate_converter": "為替レート変換", + "component.whiteboard": "黒板(縦向き)", + "component.blackboard_landscape": "黒板(横向き)", + "component.browser": "ブラウザ", + "component.office_recent_documents": "最近のドキュメント", + "whiteboard.settings.desc": "各黒板は独自のノート履歴を保持し、独立して保存します。", + "whiteboard.settings.retention.title": "ノートの保持期間", + "whiteboard.settings.retention.desc": "この黒板が保存されたノートを保持する期間を選択します。期限切れのデータは自動的に削除されます。", + "whiteboard.settings.retention.option": "{0}日", + "whiteboard.settings.instance_scope": "この保持設定は黒板コンポーネントインスタンスごとに保存されます。", + "office_recent_documents.settings.desc": "このウィジェットが最近のドキュメントをスキャンするWindowsとOfficeのソースを選択します。", + "office_recent_documents.settings.sources_title": "最近のドキュメントソース", + "office_recent_documents.settings.sources_desc": "複数のソースを組み合わせることができます。レジストリ選択は、Office相互運用MRUフォールバックも利用可能にします。", + "office_recent_documents.settings.source.registry": "OfficeレジストリMRU", + "office_recent_documents.settings.source.recent_folders": "Windowsの最近使ったフォルダ", + "office_recent_documents.settings.source.jump_lists": "Windowsジャンプリスト", + "office_recent_documents.settings.hint": "すべてのソースを無効にすると、少なくとも1つのソースが再度有効になるまで、このウィジェットは空のままになります。", + "component.removable_storage": "リムーバブルストレージ", + "component.holiday_calendar": "祝日カレンダー", + "component.study_environment": "環境", + "component.study_session_control": "学習セッション制御", + "component.study_session_history": "セッション履歴", + "component.study_noise_curve": "ノイズカーブ", + "component.study_noise_distribution": "ノイズ分布", + "component.study_score_overview": "学習スコア概要", + "component.study_deduction_reasons": "減点理由", + "component.study_interrupt_density": "中断密度", + "desktop_clock.settings.title": "時計の設定", + "desktop_clock.settings.desc": "単一時計のタイムゾーンを選択します。", + "desktop_clock.settings.timezone_label": "タイムゾーン", + "desktop_clock.settings.second_mode_label": "秒針", + "clock.second_mode.tick": "ティック", + "clock.second_mode.sweep": "スイープ", + "poetry.widget.loading_content": "詩を読み込み中...", + "poetry.widget.loading_author": "読み込み中...", + "poetry.widget.fetch_failed": "詩の取得に失敗しました", + "poetry.widget.fallback_content": "今日の詩は一時的に利用できません。", + "poetry.widget.fallback_author": "後でもう一度お試しください", + "poetry.widget.unknown_author": "不明", + "artwork.widget.loading": "読み込み中...", + "artwork.widget.loading_title": "今日のアート", + "artwork.widget.loading_subtitle": "今日の傑作を取得中", + "artwork.widget.fetch_failed": "アートの取得に失敗しました", + "artwork.widget.fallback_title": "今日のアート", + "artwork.widget.fallback_artist": "おすすめサービスは利用できません", + "artwork.widget.fallback_year": "後でもう一度お試しください", + "artwork.widget.unknown_artist": "不明なアーティスト", + "dailyword.widget.loading": "読み込み中...", + "dailyword.widget.loading_word": "今日の言葉", + "dailyword.widget.loading_pronunciation": "発音を取得中...", + "dailyword.widget.loading_meaning": "意味を取得中...", + "dailyword.widget.loading_example": "例文を取得中...", + "dailyword.widget.loading_example_translation": "読み込み中...", + "dailyword.widget.fetch_failed": "今日の言葉の取得に失敗しました", + "dailyword.widget.fallback_word": "今日の言葉", + "dailyword.widget.fallback_pronunciation": "発音は利用できません", + "dailyword.widget.fallback_meaning": "Youdao辞書は一時的に利用できません。", + "dailyword.widget.fallback_example": "更新ボタンをタップして再試行してください。", + "dailyword.widget.fallback_example_translation": "ネットワークが回復すると再試行します。", + "dailyword2x2.widget.tap_to_show": "タップして意味を表示", + "cnrnews.widget.loading": "読み込み中...", + "cnrnews.widget.loading_title": "CNRヘッドラインを取得中", + "cnrnews.widget.loading_subtitle": "お待ちください", + "cnrnews.widget.fetch_failed": "ニュースの取得に失敗しました", + "cnrnews.widget.fallback_title": "CNRニュースは一時的に利用できません", + "cnrnews.widget.fallback_subtitle": "更新をタップして再試行してください", + "cnrnews.widget.hot_label": "ホット", + "bilihot.widget.brand": "Bilibiliトレンド", + "bilihot.widget.top_right_label": "Bilibiliトレンド", + "bilihot.widget.search_entry": "検索", + "bilihot.widget.search_placeholder": "トレンドトピックを検索", + "bilihot.widget.loading": "読み込み中...", + "bilihot.widget.loading_item": "読み込み中...", + "bilihot.widget.fetch_failed": "トレンドの取得に失敗しました", + "bilihot.widget.fallback_item": "トレンドデータなし", + "bilihot.widget.more_hot": "もっとトレンドを見る", + "baiduhot.widget.brand": "Baiduトレンド", + "baiduhot.widget.loading": "読み込み中...", + "baiduhot.widget.loading_item": "読み込み中...", + "baiduhot.widget.fetch_failed": "トレンドの取得に失敗しました", + "baiduhot.widget.fallback_item": "トレンドデータなし", + "baiduhot.widget.refresh_tooltip": "更新", + "ifeng.widget.brand": "iFengニュース", + "ifeng.widget.loading": "読み込み中...", + "ifeng.widget.loading_item": "読み込み中...", + "ifeng.widget.fetch_failed": "ニュースの取得に失敗しました", + "ifeng.widget.fallback_item": "ニュースデータなし", + "ifeng.widget.refresh_tooltip": "更新", + "dailyword.settings.title": "今日の言葉の設定", + "dailyword.settings.desc": "自動更新と更新間隔を設定します。", + "dailyword.settings.auto_refresh_label": "自動更新", + "dailyword.settings.auto_refresh_enabled": "自動更新を有効にする", + "dailyword.settings.frequency_label": "更新間隔", + "bilihot.settings.title": "Bilibiliトレンドの設定", + "bilihot.settings.desc": "自動更新と更新間隔を設定します。", + "bilihot.settings.auto_refresh_label": "自動更新", + "bilihot.settings.auto_refresh_enabled": "自動更新を有効にする", + "bilihot.settings.frequency_label": "更新間隔", + "baiduhot.settings.title": "Baiduトレンドの設定", + "baiduhot.settings.desc": "ソース、自動更新、更新間隔を設定します。", + "baiduhot.settings.source_label": "データソース", + "baiduhot.settings.source_official": "公式ソース", + "baiduhot.settings.source_rss": "サードパーティRSS", + "baiduhot.settings.auto_refresh_label": "自動更新", + "baiduhot.settings.auto_refresh_enabled": "自動更新を有効にする", + "baiduhot.settings.frequency_label": "更新間隔", + "ifeng.settings.title": "iFengニュースの設定", + "ifeng.settings.desc": "チャンネル、自動更新、更新間隔を設定します。", + "ifeng.settings.channel_label": "ニュースチャンネル", + "ifeng.settings.channel_comprehensive": "総合", + "ifeng.settings.channel_mainland": "中国本土", + "ifeng.settings.channel_taiwan": "台湾", + "ifeng.settings.auto_refresh_label": "自動更新", + "ifeng.settings.auto_refresh_enabled": "自動更新を有効にする", + "ifeng.settings.frequency_label": "更新間隔", + "refresh.frequency.5m": "5分", + "refresh.frequency.10m": "10分", + "refresh.frequency.12m": "12分", + "refresh.frequency.15m": "15分", + "refresh.frequency.20m": "20分", + "refresh.frequency.30m": "30分", + "refresh.frequency.40m": "40分", + "refresh.frequency.1h": "1時間", + "refresh.frequency.3h": "3時間", + "refresh.frequency.6h": "6時間", + "refresh.frequency.12h": "12時間", + "refresh.frequency.24h": "24時間", + "weather.widget.settings.title": "天気ウィジェットの設定", + "weather.widget.settings.desc": "すべての天気ウィジェットの自動更新と更新間隔を設定します。", + "weather.widget.settings.auto_refresh_label": "自動更新", + "weather.widget.settings.auto_refresh_enabled": "自動更新を有効にする", + "weather.widget.settings.frequency_label": "更新間隔", + "weather.widget.settings.frequency_10m": "10分", + "weather.widget.settings.frequency_12m": "12分", + "weather.widget.settings.frequency_15m": "15分", + "weather.widget.settings.frequency_30m": "30分", + "weather.widget.settings.frequency_1h": "1時間", + "weather.widget.settings.frequency_3h": "3時間", + "stcn24.widget.loading": "読み込み中...", + "stcn24.widget.loading_item": "読み込み中...", + "stcn24.widget.fetch_failed": "フォーラム投稿の取得に失敗しました", + "stcn24.widget.fallback_item": "投稿なし", + "stcn24.settings.title": "STCN 24の設定", + "stcn24.settings.desc": "情報ソース、自動更新、更新間隔を設定します。", + "stcn24.settings.source_label": "情報ソース", + "stcn24.settings.source_latest_created": "最新の投稿", + "stcn24.settings.source_latest_activity": "最新のアクティビティ", + "stcn24.settings.source_most_replies": "返信数順", + "stcn24.settings.source_earliest_created": "最古の投稿", + "stcn24.settings.source_earliest_activity": "最古のアクティビティ", + "stcn24.settings.source_least_replies": "返信が少ない順", + "stcn24.settings.source_frontpage_latest": "フロントページ最新", + "stcn24.settings.source_frontpage_earliest": "フロントページ最古", + "stcn24.settings.auto_refresh_label": "自動更新", + "stcn24.settings.auto_refresh_enabled": "自動更新を有効にする", + "stcn24.settings.frequency_label": "更新間隔", + "stcn24.settings.frequency_5m": "5分", + "stcn24.settings.frequency_10m": "10分", + "stcn24.settings.frequency_20m": "20分", + "stcn24.settings.frequency_30m": "30分", + "stcn24.settings.frequency_1h": "1時間", + "stcn24.settings.frequency_3h": "3時間", + "exchange.widget.loading": "為替レートを読み込み中...", + "exchange.widget.fetch_failed": "為替レートの取得に失敗しました", + "cnrnews.settings.title": "CNRの設定", + "cnrnews.settings.desc": "自動ローテーションと更新間隔を設定します。", + "cnrnews.settings.auto_rotate_label": "自動ローテーション", + "cnrnews.settings.auto_rotate_enabled": "自動ローテーションを有効にする", + "cnrnews.settings.frequency_label": "ローテーション間隔", + "cnrnews.settings.frequency_5m": "5分", + "cnrnews.settings.frequency_10m": "10分", + "cnrnews.settings.frequency_40m": "40分", + "cnrnews.settings.frequency_1h": "1時間", + "cnrnews.settings.frequency_12h": "12時間", + "cnrnews.settings.frequency_24h": "24時間", + "artwork.settings.title": "今日のアートの設定", + "artwork.settings.desc": "今日のアートで使用されるデータソースを切り替えます。", + "artwork.settings.source_label": "ミラーソース", + "artwork.settings.source_domestic": "国内ミラー", + "artwork.settings.source_overseas": "海外ミラー", + "artwork.settings.source_status_domestic": "現在のソース: 国内ミラー(中国ネットワーク向けに最適化)", + "artwork.settings.source_status_overseas": "現在のソース: 海外ミラー(美術館のおすすめ)", + "music.widget.unsupported": "このプラットフォームでは音楽コントロールはサポートされていません", + "music.widget.unsupported_hint": "このウィジェットにはWindows SMTCが必要です", + "music.widget.no_session": "音楽ソースなし", + "music.widget.no_session_hint": "アプリストアからQQ音楽/酷狗/網易雲音楽をインストールしてください", + "music.widget.open_player": "プレーヤーを開く", + "music.widget.unknown_title": "不明なタイトル", + "music.widget.unknown_artist": "不明なアーティスト", + "music.widget.status.opened": "開かれました", + "music.widget.status.changing": "変更中", + "music.widget.status.stopped": "停止", + "music.widget.status.playing": "再生中", + "music.widget.status.paused": "一時停止", + "recording.widget.title": "レコーダー", + "recording.widget.hint.ready": "赤いボタンをタップして録音", + "recording.widget.hint.recording": "録音中", + "recording.widget.hint.paused": "一時停止", + "recording.widget.hint.unsupported": "マイクが利用できません", + "recording.widget.hint.error": "録音に失敗しました", + "recording.widget.hint.saved_format": "保存しました {0}", + "recording.widget.save_picker_title": "録音ファイルを保存", + "recording.widget.save_picker_type": "WAVオーディオ", + "study.environment.status_label": "環境", + "study.environment.status.initializing": "初期化中", + "study.environment.status.ready": "準備完了", + "study.environment.status.quiet": "静か", + "study.environment.status.noisy": "うるさい", + "study.environment.status.paused": "一時停止", + "study.environment.status.error": "エラー", + "study.environment.status.unsupported": "未対応", + "study.environment.value.unavailable": "--", + "study.environment.value.display_format": "{0:F1} dB", + "study.environment.value.dbfs_format": "{0:F1} dBFS", + "study.environment.settings.title": "環境ウィジェットの設定", + "study.environment.settings.desc": "右側のリアルタイムノイズ値表示を設定します。", + "study.environment.settings.show_display_db": "表示dBを表示", + "study.environment.settings.show_dbfs": "dBFSを表示", + "study.environment.settings.hint": "少なくとも1つの表示モードを有効にしておく必要があります。", + "removable_storage.settings.desc": "接続されたUSBドライブを表示し、クイックオープンと取り出しアクションを提供します。", + "removable_storage.settings.behavior_title": "動作", + "removable_storage.settings.behavior_desc": "ウィジェットはリムーバブルドライブを自動的に監視し、最新の挿入されたUSBドライブに切り替わります。", + "removable_storage.action.open": "開く", + "removable_storage.action.eject": "取り出し", + "removable_storage.widget.default_name": "リムーバブルドライブ", + "removable_storage.widget.empty_title": "デバイスが挿入されていません", + "removable_storage.widget.empty_subtitle": "USBドライブを挿入してここに表示します。", + "removable_storage.widget.empty_hint": "リムーバブルデバイスが挿入されるまで、ボタンは無効のままです。", + "removable_storage.widget.ready": "開くか取り出す準備ができました。", + "removable_storage.widget.ejecting": "ドライブを取り出し中...", + "removable_storage.widget.eject_failed": "このドライブを取り出せませんでした。上のファイルを閉じて再試行してください。", + "removable_storage.widget.open_failed": "このドライブを開けませんでした。", + "removable_storage.widget.refresh_failed": "ドライブリストの更新に失敗しました。", + "study.session_control.action.start": "学習セッションを開始", + "study.session_control.action.stop": "学習セッションを停止", + "study.session_control.idle_hint": "右のボタンをタップして開始", + "study.session_control.report_preview": "レポートをプレビュー", + "study.session_control.report_confirm_hint": "右のボタンをタップして確認", + "study.session_control.running_elapsed_format": "経過 {0}", + "study.session_control.last_session_format": "前回 {0}", + "study.session_control.start_failed": "セッションを開始できません", + "study.session_control.stop_failed": "セッションを停止できません", + "study.session_history.title": "セッション履歴", + "study.session_history.empty": "セッション履歴なし", + "study.session_history.select_failed": "セッションを切り替えられません", + "study.session_history.rename_failed": "セッション名を変更できません", + "study.session_history.delete_failed": "セッションを削除できません", + "study.session_history.rename_placeholder": "セッション名を入力", + "study.session_history.rename_confirm": "名前変更を確認", + "study.session_history.rename_cancel": "名前変更をキャンセル", + "study.session_history.loading": "データを読み込み中...", + "study.session_history.loaded": "データが読み込まれました", + "study.session_history.duration_format": "{0:hh\\:mm\\:ss}", + "study.session_history.meta_format": "{0} · 平均 {1:F1}", + "study.session_history.action.view": "表示", + "study.session_history.action.rename": "名前変更", + "study.session_history.action.delete": "削除", + "study.session_history.dialog.rename_title": "セッション名を変更", + "study.session_history.dialog.rename_message": "「{0}」の新しい名前を入力してください。", + "study.session_history.dialog.delete_title": "セッションを削除", + "study.session_history.dialog.delete_message": "「{0}」を削除しますか?これは元に戻せません。", + "study.session_history.dialog.delete_confirm": "削除", + "study.noise_curve.value_format": "{0:F1} dB", + "study.noise_curve.axis.now": "現在", + "study.noise_distribution.title": "ノイズレベル分布", + "study.noise_distribution.mode.realtime": "リアルタイム", + "study.noise_distribution.mode.session": "セッション", + "study.noise_distribution.summary.mainly_format": "主に: {0}", + "study.noise_distribution.summary.latest_format": "最新: {0}", + "study.noise_distribution.summary.compact_format": "主 {0} · 新 {1}", + "study.noise_distribution.level.quiet": "静か", + "study.noise_distribution.level.normal": "普通", + "study.noise_distribution.level.noisy": "うるさい", + "study.noise_distribution.level.extreme": "極端", + "study.noise_distribution.axis.extreme": "極端", + "study.noise_distribution.axis.noisy": "うるさい", + "study.noise_distribution.axis.normal": "普通", + "study.noise_distribution.axis.quiet": "静か", + "study.noise_distribution.axis.now": "現在", + "study.score_overview.title": "学習スコア", + "study.score_overview.mode.realtime": "リアルタイム", + "study.score_overview.mode.session": "セッション", + "study.score_overview.current": "現在", + "study.score_overview.average": "平均", + "study.score_overview.minimum": "最小", + "study.score_overview.maximum": "最大", + "study.score_overview.average_short": "平均", + "study.score_overview.minimum_short": "最小", + "study.score_overview.maximum_short": "最大", + "study.score_overview.unavailable": "--", + "study.deduction.title": "減点理由", + "study.deduction.mode.realtime": "リアルタイム", + "study.deduction.mode.session": "セッション", + "study.deduction.reason.sustained": "持続ノイズ", + "study.deduction.reason.time": "閾値超過時間", + "study.deduction.reason.segment": "中断頻度", + "study.deduction.reason.sustained_short": "持続", + "study.deduction.reason.time_short": "時間", + "study.deduction.reason.segment_short": "中断", + "study.deduction.metric.sustained_format": "p50 {0:F1} dBFS", + "study.deduction.metric.sustained_short_format": "p50 {0:F1}", + "study.deduction.metric.time_format": "{0:F1}%超過", + "study.deduction.metric.time_short_format": "{0:F1}%", + "study.deduction.metric.segment_format": "{0:F1}/分", + "study.deduction.metric.segment_short_format": "{0:F1}/分", + "study.deduction.loss_format": "-{0:F1}", + "study.deduction.total_loss_format": "合計 -{0:F1}", + "study.deduction.total_score_format": "スコア {0:F1}", + "study.deduction.total_loss_unavailable": "合計 {0}", + "study.deduction.total_score_unavailable": "スコア {0}", + "study.deduction.unavailable": "--", + "study.interrupt_density.title": "中断密度", + "study.interrupt_density.mode.realtime": "リアルタイム", + "study.interrupt_density.mode.session": "セッション", + "study.interrupt_density.unit": "/分", + "study.interrupt_density.segment_count": "中断回数", + "study.interrupt_density.segment_count_short": "回数", + "study.interrupt_density.duration": "期間", + "study.interrupt_density.duration_short": "時間", + "study.interrupt_density.density_value_format": "{0:F1}", + "study.interrupt_density.segment_count_value_format": "{0}", + "study.interrupt_density.level_format": "レベル {0}", + "study.interrupt_density.level.calm": "穏やか", + "study.interrupt_density.level.normal": "普通", + "study.interrupt_density.level.frequent": "頻繁", + "study.interrupt_density.level.severe": "深刻", + "study.interrupt_density.threshold_format": "ペナルティ閾値 {0:F1}/分", + "study.interrupt_density.unavailable": "--", + "desktop.add_page": "ページを追加", + "desktop.delete_page": "ページを削除", + "placement.fill": "フィル", + "placement.fit": "フィット", + "placement.stretch": "ストレッチ", + "placement.center": "中央", + "placement.tile": "タイル", + "single_instance.notice.title": "アプリは既に実行中", + "single_instance.notice.description": "アプリは既に実行中です。複数回クリックして開く必要はありません。", + "single_instance.notice.button": "OK", + "market.status.install_success_restart_format": "✓ プラグイン「{0}」が正常にインストールされました!有効にするには、アプリケーションを再起動してください。", + "market.dialog.restart_message_format": "プラグイン「{0}」が正常にインストールされました。\n\nこのプラグインを使用するには、今すぐアプリケーションを再起動する必要があります。\n\n再起動しますか?", + "component.settings.color_scheme": "カラースキーム" +} diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index dd3f02d..84dd4d4 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -271,6 +271,7 @@ "settings.region.language_label": "语言", "settings.region.language_zh": "中文", "settings.region.language_en": "英文", + "settings.region.language_ja": "日文", "settings.region.timezone_header": "时区", "settings.region.timezone_desc": "选择时区。时钟与日历组件会使用该时区。", "settings.region.applied_format": "语言已切换为:{0}", diff --git a/LanMountainDesktop/Services/LocalizationService.cs b/LanMountainDesktop/Services/LocalizationService.cs index 239cab7..961d907 100644 --- a/LanMountainDesktop/Services/LocalizationService.cs +++ b/LanMountainDesktop/Services/LocalizationService.cs @@ -36,9 +36,17 @@ public sealed class LocalizationService public string NormalizeLanguageCode(string? languageCode) { - return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase) - ? "en-US" - : "zh-CN"; + if (string.IsNullOrWhiteSpace(languageCode)) + { + return "zh-CN"; + } + + return languageCode.ToLowerInvariant() switch + { + "en-us" or "en" => "en-US", + "ja-jp" or "ja" => "ja-JP", + _ => "zh-CN" + }; } public string GetString(string languageCode, string key, string fallback) diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs index eedae2f..b46b755 100644 --- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs +++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs @@ -326,7 +326,8 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase return [ new SelectionOption("zh-CN", L("settings.region.language_zh", "中文")), - new SelectionOption("en-US", L("settings.region.language_en", "English")) + new SelectionOption("en-US", L("settings.region.language_en", "English")), + new SelectionOption("ja-JP", L("settings.region.language_ja", "日本語")) ]; } diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs index d81b238..4165354 100644 --- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs @@ -13,6 +13,7 @@ using Avalonia.VisualTree; using FluentIcons.Avalonia; using FluentIcons.Common; using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.DesktopEditing; using LanMountainDesktop.Host.Abstractions; using LanMountainDesktop.Models; using LanMountainDesktop.Services; @@ -34,12 +35,6 @@ public partial class MainWindow private const string DesktopComponentContentHostTag = "desktop-component-content-host"; private const string DesktopComponentResizeHandleTag = "desktop-component-resize-handle"; - private bool _isDesktopComponentDragActive; - private DesktopComponentDragState? _desktopComponentDrag; - private Border? _desktopComponentDragGhost; - private bool _isDesktopComponentResizeActive; - private DesktopComponentResizeState? _desktopComponentResize; - private string? _componentLibraryActiveCategoryId; private int _componentLibraryCategoryIndex; private int _componentLibraryComponentIndex; @@ -58,43 +53,6 @@ public partial class MainWindow private Point _componentLibraryComponentGestureCurrentPoint; private double _componentLibraryComponentGestureBaseOffset; - private enum DesktopComponentDragKind - { - None, - NewFromLibrary, - MoveExisting - } - - private sealed class DesktopComponentDragState - { - public DesktopComponentDragKind Kind { get; init; } - public string ComponentId { get; init; } = string.Empty; - public string PlacementId { get; init; } = string.Empty; - public int PageIndex { get; init; } - public int WidthCells { get; init; } - public int HeightCells { get; init; } - public Point PointerOffset { get; init; } - public Border? SourceHost { get; init; } - public int TargetRow { get; set; } - public int TargetColumn { get; set; } - } - - private sealed class DesktopComponentResizeState - { - public string PlacementId { get; init; } = string.Empty; - public string ComponentId { get; init; } = string.Empty; - public Border SourceHost { get; init; } = null!; - public int StartWidthCells { get; init; } - public int StartHeightCells { get; init; } - public int MinWidthCells { get; init; } - public int MinHeightCells { get; init; } - public int MaxWidthCells { get; init; } - public int MaxHeightCells { get; init; } - public Point StartPointerInViewport { get; init; } - public int CurrentWidthCells { get; set; } - public int CurrentHeightCells { get; set; } - } - private sealed record ComponentLibraryCategory( string Id, Symbol Icon, @@ -1971,7 +1929,7 @@ public partial class MainWindow private void OnDesktopComponentHostPointerPressed(object? sender, PointerPressedEventArgs e) { - if (!_isComponentLibraryOpen || _isDesktopComponentDragActive || _isDesktopComponentResizeActive) + if (!_isComponentLibraryOpen || HasActiveDesktopEditSession) { return; } @@ -2003,7 +1961,7 @@ public partial class MainWindow if (IsPointerOnSelectedFrameBorder(host, pointerInHost)) { BeginDesktopComponentResizeDrag(host, placement, e); - if (_isDesktopComponentResizeActive) + if (IsDesktopEditResizeMode) { e.Handled = true; } @@ -2065,10 +2023,9 @@ public partial class MainWindow private void BeginDesktopComponentMoveDrag(Border sourceHost, DesktopComponentPlacementSnapshot placement, PointerPressedEventArgs e) { - if (_isDesktopComponentResizeActive || - DesktopEditDragLayer is null || + if (HasActiveDesktopEditSession || DesktopPagesViewport is null || - _currentDesktopCellSize <= 0 || + !TryGetCurrentDesktopGridGeometry(out var grid) || !_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var runtimeDescriptor)) { return; @@ -2082,27 +2039,31 @@ public partial class MainWindow placement.HeightCells)); var pointerInViewport = e.GetPosition(DesktopPagesViewport); - var pitch = CurrentDesktopPitch; - var topLeft = new Point(placement.Column * pitch, placement.Row * pitch); - var pointerOffset = pointerInViewport - topLeft; + _desktopEditOriginalRect = DesktopPlacementMath.GetCellRect(grid, placement.Column, placement.Row, widthCells, heightCells); + _desktopEditStartRow = placement.Row; + _desktopEditStartColumn = placement.Column; + var pointerOffset = DesktopPlacementMath.Subtract( + pointerInViewport, + new Point(_desktopEditOriginalRect.X, _desktopEditOriginalRect.Y)); - sourceHost.Opacity = 0.35; + _desktopEditSession = DesktopEditSession.CreateDraggingExisting( + placement.ComponentId, + placement.PlacementId, + placement.PageIndex, + widthCells, + heightCells, + pointerInViewport, + pointerOffset, + GetComponentLibraryBoundsInViewport()); - _desktopComponentDrag = new DesktopComponentDragState - { - Kind = DesktopComponentDragKind.MoveExisting, - ComponentId = placement.ComponentId, - PlacementId = placement.PlacementId, - PageIndex = placement.PageIndex, - WidthCells = widthCells, - HeightCells = heightCells, - PointerOffset = pointerOffset, - SourceHost = sourceHost - }; - _isDesktopComponentDragActive = true; - - EnsureDesktopComponentDragGhost(placement.ComponentId, widthCells, heightCells); - UpdateDesktopComponentDragVisual(pointerInViewport); + SetDesktopEditSourceHost(sourceHost, 0.22); + EnsureDesktopEditOverlayPresenter(); + UpdateDesktopEditOverlayMetadata(placement.ComponentId, widthCells, heightCells, L("component.move", "Move")); + _desktopEditOverlayPresenter?.SetPreviewRect(_desktopEditOriginalRect); + _desktopEditOverlayPresenter?.SetCandidateRect(_desktopEditOriginalRect); + _desktopEditOverlayPresenter?.SetInvalid(false); + _desktopEditOverlayPresenter?.Show(); + UpdateDesktopEditSession(pointerInViewport); e.Pointer.Capture(this); } @@ -2110,9 +2071,7 @@ public partial class MainWindow private void BeginDesktopComponentNewDrag(string componentId, PointerPressedEventArgs e) { if (!_isComponentLibraryOpen || - _isDesktopComponentDragActive || - _isDesktopComponentResizeActive || - DesktopEditDragLayer is null || + HasActiveDesktopEditSession || DesktopPagesViewport is null || _currentDesktopCellSize <= 0 || !_componentRuntimeRegistry.TryGetDescriptor(componentId, out var runtimeDescriptor) || @@ -2128,74 +2087,35 @@ public partial class MainWindow runtimeDescriptor.Definition.MinWidthCells, runtimeDescriptor.Definition.MinHeightCells)); - // Center the component under the pointer while dragging from the library. - var ghostWidth = Math.Max(1, widthCells * _currentDesktopCellSize + Math.Max(0, widthCells - 1) * _currentDesktopCellGap); - var ghostHeight = Math.Max(1, heightCells * _currentDesktopCellSize + Math.Max(0, heightCells - 1) * _currentDesktopCellGap); - var pointerOffset = new Point( - ghostWidth * 0.5, - ghostHeight * 0.5); - - _desktopComponentDrag = new DesktopComponentDragState - { - Kind = DesktopComponentDragKind.NewFromLibrary, - ComponentId = componentId, - PageIndex = _currentDesktopSurfaceIndex, - WidthCells = widthCells, - HeightCells = heightCells, - PointerOffset = pointerOffset - }; - _isDesktopComponentDragActive = true; - - EnsureDesktopComponentDragGhost(componentId, widthCells, heightCells); var pointerInViewport = e.GetPosition(DesktopPagesViewport); - UpdateDesktopComponentDragVisual(pointerInViewport); + var previewSize = GetComponentPixelSize(widthCells, heightCells, _currentDesktopCellSize, _currentDesktopCellGap); + var pointerOffset = new Point(previewSize.Width * 0.5, previewSize.Height * 0.5); + + _desktopEditOriginalRect = new Rect( + DesktopPlacementMath.Subtract(pointerInViewport, pointerOffset), + previewSize); + _desktopEditSession = DesktopEditSession.CreatePendingNew( + componentId, + _currentDesktopSurfaceIndex, + widthCells, + heightCells, + pointerInViewport, + pointerOffset, + GetComponentLibraryBoundsInViewport()); + + EnsureDesktopEditOverlayPresenter(); + UpdateDesktopEditOverlayMetadata(componentId, widthCells, heightCells, L("component_library.drag_hint", "Drag to place")); + _desktopEditOverlayPresenter?.SetPreviewRect(_desktopEditOriginalRect); + _desktopEditOverlayPresenter?.SetCandidateRect(null); + _desktopEditOverlayPresenter?.SetInvalid(false); e.Pointer.Capture(this); } - private void EnsureDesktopComponentDragGhost(string componentId, int widthCells, int heightCells) - { - if (DesktopEditDragLayer is null) - { - return; - } - - DesktopEditDragLayer.Children.Clear(); - - var ghostWidth = Math.Max(1, widthCells * _currentDesktopCellSize + Math.Max(0, widthCells - 1) * _currentDesktopCellGap); - var ghostHeight = Math.Max(1, heightCells * _currentDesktopCellSize + Math.Max(0, heightCells - 1) * _currentDesktopCellGap); - - var ghostContent = CreateDesktopComponentControl(componentId); - if (ghostContent is not null) - { - ghostContent.IsHitTestVisible = false; - } - - var visualInset = GetDesktopComponentVisualInset(widthCells, heightCells); - - _desktopComponentDragGhost = new Border - { - Width = ghostWidth, - Height = ghostHeight, - CornerRadius = new CornerRadius(Math.Clamp(_currentDesktopCellSize * 0.45, 16, 36)), - Background = new SolidColorBrush(Color.Parse("#331E40AF")), - BorderBrush = GetThemeBrush("AdaptiveAccentBrush"), - BorderThickness = new Thickness(Math.Clamp(_currentDesktopCellSize * 0.04, 1, 3)), - Padding = visualInset, - ClipToBounds = true, - Child = ghostContent, - Opacity = 0.92, - IsHitTestVisible = false - }; - - DesktopEditDragLayer.Children.Add(_desktopComponentDragGhost); - } - private void OnDesktopComponentResizeHandlePointerPressed(object? sender, PointerPressedEventArgs e) { if (!_isComponentLibraryOpen || - _isDesktopComponentDragActive || - _isDesktopComponentResizeActive || + HasActiveDesktopEditSession || DesktopPagesViewport is null || sender is not Border handle || !e.GetCurrentPoint(handle).Properties.IsLeftButtonPressed) @@ -2218,7 +2138,7 @@ public partial class MainWindow SetSelectedDesktopComponent(host); BeginDesktopComponentResizeDrag(host, placement, e); - if (_isDesktopComponentResizeActive) + if (IsDesktopEditResizeMode) { e.Handled = true; } @@ -2229,10 +2149,12 @@ public partial class MainWindow DesktopComponentPlacementSnapshot placement, PointerPressedEventArgs e) { - if (DesktopPagesViewport is null || + if (HasActiveDesktopEditSession || + DesktopPagesViewport is null || _currentDesktopCellSize <= 0 || !_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var runtimeDescriptor) || - !_desktopPageComponentGrids.TryGetValue(placement.PageIndex, out var pageGrid)) + !_desktopPageComponentGrids.TryGetValue(placement.PageIndex, out var pageGrid) || + !TryGetCurrentDesktopGridGeometry(out var grid)) { return; } @@ -2259,152 +2181,58 @@ public partial class MainWindow } var pointerInViewport = e.GetPosition(DesktopPagesViewport); - _desktopComponentResize = new DesktopComponentResizeState + _desktopEditOriginalRect = DesktopPlacementMath.GetCellRect( + grid, + placement.Column, + placement.Row, + startSpan.WidthCells, + startSpan.HeightCells); + _desktopEditStartWidthCells = startSpan.WidthCells; + _desktopEditStartHeightCells = startSpan.HeightCells; + _desktopEditMinWidthCells = Math.Max(1, Math.Min(minSpan.WidthCells, maxWidthCells)); + _desktopEditMinHeightCells = Math.Max(1, Math.Min(minSpan.HeightCells, maxHeightCells)); + _desktopEditMaxWidthCells = maxWidthCells; + _desktopEditMaxHeightCells = maxHeightCells; + _desktopEditResizeMode = runtimeDescriptor.Definition.ResizeMode; + + _desktopEditSession = DesktopEditSession.CreateResizingExisting( + placement.ComponentId, + placement.PlacementId, + placement.PageIndex, + startSpan.WidthCells, + startSpan.HeightCells, + pointerInViewport, + GetComponentLibraryBoundsInViewport()) with { - PlacementId = placement.PlacementId, - ComponentId = placement.ComponentId, - SourceHost = sourceHost, - StartWidthCells = startSpan.WidthCells, - StartHeightCells = startSpan.HeightCells, - MinWidthCells = Math.Max(1, Math.Min(minSpan.WidthCells, maxWidthCells)), - MinHeightCells = Math.Max(1, Math.Min(minSpan.HeightCells, maxHeightCells)), - MaxWidthCells = maxWidthCells, - MaxHeightCells = maxHeightCells, - StartPointerInViewport = pointerInViewport, - CurrentWidthCells = startSpan.WidthCells, - CurrentHeightCells = startSpan.HeightCells + TargetRow = placement.Row, + TargetColumn = placement.Column }; - _isDesktopComponentResizeActive = true; - sourceHost.Opacity = 0.96; + SetDesktopEditSourceHost(sourceHost, 0.22); + EnsureDesktopEditOverlayPresenter(); + UpdateDesktopEditOverlayMetadata(placement.ComponentId, startSpan.WidthCells, startSpan.HeightCells, L("component.resize", "Resize")); + _desktopEditOverlayPresenter?.SetPreviewRect(_desktopEditOriginalRect); + _desktopEditOverlayPresenter?.SetCandidateRect(_desktopEditOriginalRect); + _desktopEditOverlayPresenter?.SetInvalid(false); + _desktopEditOverlayPresenter?.Show(); + UpdateDesktopEditSession(pointerInViewport); e.Pointer.Capture(this); } - private void UpdateDesktopComponentResizeVisual(Point pointerInViewport) - { - if (_desktopComponentResize is null) - { - return; - } - - var pitch = CurrentDesktopPitch; - if (pitch <= 0 || - _desktopComponentResize.StartWidthCells <= 0 || - _desktopComponentResize.StartHeightCells <= 0) - { - return; - } - - var deltaX = pointerInViewport.X - _desktopComponentResize.StartPointerInViewport.X; - var deltaY = pointerInViewport.Y - _desktopComponentResize.StartPointerInViewport.Y; - int widthCells; - int heightCells; - if (GetComponentResizeMode(_desktopComponentResize.ComponentId) == DesktopComponentResizeMode.Free) - { - widthCells = Math.Clamp( - (int)Math.Round(_desktopComponentResize.StartWidthCells + deltaX / pitch), - _desktopComponentResize.MinWidthCells, - _desktopComponentResize.MaxWidthCells); - heightCells = Math.Clamp( - (int)Math.Round(_desktopComponentResize.StartHeightCells + deltaY / pitch), - _desktopComponentResize.MinHeightCells, - _desktopComponentResize.MaxHeightCells); - } - else - { - var widthScale = (_desktopComponentResize.StartWidthCells + deltaX / pitch) / _desktopComponentResize.StartWidthCells; - var heightScale = (_desktopComponentResize.StartHeightCells + deltaY / pitch) / _desktopComponentResize.StartHeightCells; - - var proposedScale = Math.Max(widthScale, heightScale); - var minScale = Math.Max( - (double)_desktopComponentResize.MinWidthCells / _desktopComponentResize.StartWidthCells, - (double)_desktopComponentResize.MinHeightCells / _desktopComponentResize.StartHeightCells); - var maxScale = Math.Min( - (double)_desktopComponentResize.MaxWidthCells / _desktopComponentResize.StartWidthCells, - (double)_desktopComponentResize.MaxHeightCells / _desktopComponentResize.StartHeightCells); - - if (double.IsNaN(proposedScale) || double.IsInfinity(proposedScale)) - { - proposedScale = minScale; - } - - if (maxScale < minScale) - { - maxScale = minScale; - } - - var scale = Math.Clamp(proposedScale, minScale, maxScale); - widthCells = Math.Clamp( - (int)Math.Round(_desktopComponentResize.StartWidthCells * scale), - _desktopComponentResize.MinWidthCells, - _desktopComponentResize.MaxWidthCells); - heightCells = Math.Clamp( - (int)Math.Round(_desktopComponentResize.StartHeightCells * scale), - _desktopComponentResize.MinHeightCells, - _desktopComponentResize.MaxHeightCells); - } - - var normalized = NormalizeComponentCellSpan(_desktopComponentResize.ComponentId, (widthCells, heightCells)); - widthCells = Math.Clamp(normalized.WidthCells, _desktopComponentResize.MinWidthCells, _desktopComponentResize.MaxWidthCells); - heightCells = Math.Clamp(normalized.HeightCells, _desktopComponentResize.MinHeightCells, _desktopComponentResize.MaxHeightCells); - - _desktopComponentResize.CurrentWidthCells = widthCells; - _desktopComponentResize.CurrentHeightCells = heightCells; - Grid.SetColumnSpan(_desktopComponentResize.SourceHost, widthCells); - Grid.SetRowSpan(_desktopComponentResize.SourceHost, heightCells); - } - - private bool TryCompleteDesktopComponentResize(Point pointerInViewport) - { - if (_desktopComponentResize is null) - { - return false; - } - - UpdateDesktopComponentResizeVisual(pointerInViewport); - - var placement = _desktopComponentPlacements.FirstOrDefault(p => - string.Equals(p.PlacementId, _desktopComponentResize.PlacementId, StringComparison.OrdinalIgnoreCase)); - if (placement is null) - { - return false; - } - - var before = ClonePlacementSnapshot(placement); - - var widthCells = Math.Max(1, _desktopComponentResize.CurrentWidthCells); - var heightCells = Math.Max(1, _desktopComponentResize.CurrentHeightCells); - var changed = placement.WidthCells != widthCells || placement.HeightCells != heightCells; - placement.WidthCells = widthCells; - placement.HeightCells = heightCells; - - ApplyDesktopEditStateToHost(_desktopComponentResize.SourceHost, _isComponentLibraryOpen); - if (changed) - { - PersistSettings(); - TelemetryServices.Usage?.TrackDesktopComponentResized(before, ClonePlacementSnapshot(placement), "component.resize"); - } - - return true; - } - private void CancelDesktopComponentResize(bool restoreOriginalSpan) { - if (!_isDesktopComponentResizeActive || _desktopComponentResize is null) + if (!IsDesktopEditResizeMode && !_isDesktopEditCommitPending) { return; } - if (restoreOriginalSpan) + if (restoreOriginalSpan && _desktopEditSourceHost is not null) { - Grid.SetColumnSpan(_desktopComponentResize.SourceHost, _desktopComponentResize.StartWidthCells); - Grid.SetRowSpan(_desktopComponentResize.SourceHost, _desktopComponentResize.StartHeightCells); + Grid.SetColumnSpan(_desktopEditSourceHost, Math.Max(1, _desktopEditStartWidthCells)); + Grid.SetRowSpan(_desktopEditSourceHost, Math.Max(1, _desktopEditStartHeightCells)); } - _desktopComponentResize.SourceHost.Opacity = 1; - ApplyDesktopEditStateToHost(_desktopComponentResize.SourceHost, _isComponentLibraryOpen); - _desktopComponentResize = null; - _isDesktopComponentResizeActive = false; + CancelDesktopEditSession(animate: false); } private void OnDesktopComponentDragPointerMoved(object? sender, PointerEventArgs e) @@ -2414,18 +2242,12 @@ public partial class MainWindow return; } - if (_isDesktopComponentResizeActive && _desktopComponentResize is not null) - { - UpdateDesktopComponentResizeVisual(e.GetPosition(DesktopPagesViewport)); - return; - } - - if (!_isDesktopComponentDragActive || _desktopComponentDrag is null) + if (!HasActiveDesktopEditSession || _isDesktopEditCommitPending) { return; } - UpdateDesktopComponentDragVisual(e.GetPosition(DesktopPagesViewport)); + UpdateDesktopEditSession(e.GetPosition(DesktopPagesViewport)); } private void OnDesktopComponentDragPointerReleased(object? sender, PointerReleasedEventArgs e) @@ -2435,28 +2257,18 @@ public partial class MainWindow return; } - if (_isDesktopComponentResizeActive && _desktopComponentResize is not null) - { - var resizePointerInViewport = e.GetPosition(DesktopPagesViewport); - var resizeSuccess = TryCompleteDesktopComponentResize(resizePointerInViewport); - CancelDesktopComponentResize(restoreOriginalSpan: !resizeSuccess); - e.Pointer.Capture(null); - if (resizeSuccess) - { - e.Handled = true; - } - - return; - } - - if (!_isDesktopComponentDragActive || _desktopComponentDrag is null) + if (!HasActiveDesktopEditSession) { return; } var pointerInViewport = e.GetPosition(DesktopPagesViewport); - var success = TryCompleteDesktopComponentDrag(pointerInViewport); - CancelDesktopComponentDrag(); + var success = CompleteDesktopEditSession(pointerInViewport); + if (!success) + { + CancelDesktopEditSession(animate: !_desktopEditSession.IsPendingNew); + } + e.Pointer.Capture(null); if (success) { @@ -2466,142 +2278,56 @@ public partial class MainWindow private void OnDesktopComponentDragPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) { - if (_isDesktopComponentResizeActive) - { - CancelDesktopComponentResize(restoreOriginalSpan: true); - return; - } - - if (!_isDesktopComponentDragActive) + if (_isDesktopEditCommitPending) { return; } - CancelDesktopComponentDrag(); - } - - private void UpdateDesktopComponentDragVisual(Point pointerInViewport) - { - if (_desktopComponentDragGhost is null || _desktopComponentDrag is null || DesktopPagesViewport is null) + if (!HasActiveDesktopEditSession) { return; } - var withinViewport = - pointerInViewport.X >= 0 && - pointerInViewport.Y >= 0 && - pointerInViewport.X <= DesktopPagesViewport.Bounds.Width && - pointerInViewport.Y <= DesktopPagesViewport.Bounds.Height; - - if (!withinViewport || - !TryGetDesktopComponentDropCell(pointerInViewport, _desktopComponentDrag, out var row, out var column)) - { - _desktopComponentDragGhost.IsVisible = false; - return; - } - - _desktopComponentDragGhost.IsVisible = true; - _desktopComponentDrag.TargetRow = row; - _desktopComponentDrag.TargetColumn = column; - var pitch = CurrentDesktopPitch; - Canvas.SetLeft(_desktopComponentDragGhost, column * pitch); - Canvas.SetTop(_desktopComponentDragGhost, row * pitch); - } - - private bool TryGetDesktopComponentDropCell( - Point pointerInViewport, - DesktopComponentDragState state, - out int row, - out int column) - { - row = 0; - column = 0; - - if (_currentDesktopCellSize <= 0 || - _currentDesktopSurfaceIndex < 0 || - _currentDesktopSurfaceIndex >= _desktopPageCount || - !_desktopPageComponentGrids.TryGetValue(_currentDesktopSurfaceIndex, out var pageGrid)) - { - return false; - } - - var maxColumns = pageGrid.ColumnDefinitions.Count; - var maxRows = pageGrid.RowDefinitions.Count; - if (maxColumns <= 0 || maxRows <= 0) - { - return false; - } - - var pitch = CurrentDesktopPitch; - if (pitch <= 0) - { - return false; - } - - var x = pointerInViewport.X - state.PointerOffset.X; - var y = pointerInViewport.Y - state.PointerOffset.Y; - - column = (int)Math.Floor(x / pitch); - row = (int)Math.Floor(y / pitch); - - column = Math.Clamp(column, 0, Math.Max(0, maxColumns - state.WidthCells)); - row = Math.Clamp(row, 0, Math.Max(0, maxRows - state.HeightCells)); - return true; - } - - private bool TryCompleteDesktopComponentDrag(Point pointerInViewport) - { - if (_desktopComponentDrag is null || - _currentDesktopCellSize <= 0 || - _currentDesktopSurfaceIndex < 0 || - _currentDesktopSurfaceIndex >= _desktopPageCount) - { - return false; - } - - if (!TryGetDesktopComponentDropCell(pointerInViewport, _desktopComponentDrag, out var row, out var column)) - { - return false; - } - - switch (_desktopComponentDrag.Kind) - { - case DesktopComponentDragKind.NewFromLibrary: - PlaceDesktopComponentOnPage(_desktopComponentDrag.ComponentId, _currentDesktopSurfaceIndex, row, column); - return true; - case DesktopComponentDragKind.MoveExisting: - return TryMoveExistingDesktopComponent(_desktopComponentDrag.PlacementId, row, column); - default: - return false; - } + CancelDesktopEditSession(animate: !_desktopEditSession.IsPendingNew); } private bool TryMoveExistingDesktopComponent(string placementId, int row, int column) { - if (string.IsNullOrWhiteSpace(placementId) || - _desktopComponentDrag?.SourceHost is null || - _desktopComponentDrag.Kind != DesktopComponentDragKind.MoveExisting) + if (string.IsNullOrWhiteSpace(placementId)) { return false; } - var placement = _desktopComponentPlacements.FirstOrDefault(p => - string.Equals(p.PlacementId, placementId, StringComparison.OrdinalIgnoreCase)); - if (placement is null) + if (!TryGetDesktopPlacementById(placementId, out var placement)) { return false; } var before = ClonePlacementSnapshot(placement); + if (!DesktopPlacementMath.HasCellPositionChanged(placement.Row, placement.Column, row, column)) + { + return false; + } + + var host = _desktopEditSourceHost; + if (host is null && + _desktopPageComponentGrids.TryGetValue(placement.PageIndex, out var pageGrid)) + { + host = pageGrid.Children + .OfType() + .FirstOrDefault(candidate => string.Equals(candidate.Tag as string, placementId, StringComparison.OrdinalIgnoreCase)); + } placement.Row = Math.Max(0, row); placement.Column = Math.Max(0, column); - Grid.SetRow(_desktopComponentDrag.SourceHost, placement.Row); - Grid.SetColumn(_desktopComponentDrag.SourceHost, placement.Column); + if (host is not null) + { + Grid.SetRow(host, placement.Row); + Grid.SetColumn(host, placement.Column); + ApplyDesktopEditStateToHost(host, _isComponentLibraryOpen); + } - _desktopComponentDrag.SourceHost.Opacity = 1; - ApplyDesktopEditStateToHost(_desktopComponentDrag.SourceHost, _isComponentLibraryOpen); PersistSettings(); TelemetryServices.Usage?.TrackDesktopComponentMoved(before, ClonePlacementSnapshot(placement), "component.move"); return true; @@ -2609,26 +2335,12 @@ public partial class MainWindow private void CancelDesktopComponentDrag() { - if (!_isDesktopComponentDragActive) + if (!IsDesktopEditDragMode && !_isDesktopEditCommitPending) { return; } - if (_desktopComponentDrag?.SourceHost is not null) - { - _desktopComponentDrag.SourceHost.Opacity = 1; - ApplyDesktopEditStateToHost(_desktopComponentDrag.SourceHost, _isComponentLibraryOpen); - } - - _desktopComponentDrag = null; - _isDesktopComponentDragActive = false; - - if (DesktopEditDragLayer is not null) - { - DesktopEditDragLayer.Children.Clear(); - } - - _desktopComponentDragGhost = null; + CancelDesktopEditSession(animate: false); } private void ShowComponentLibraryCategoryView() @@ -3158,7 +2870,7 @@ public partial class MainWindow } BeginDesktopComponentNewDrag(componentId, e); - if (_isDesktopComponentDragActive) + if (HasActiveDesktopEditSession) { e.Handled = true; } diff --git a/LanMountainDesktop/Views/MainWindow.DesktopEditing.cs b/LanMountainDesktop/Views/MainWindow.DesktopEditing.cs new file mode 100644 index 0000000..db89038 --- /dev/null +++ b/LanMountainDesktop/Views/MainWindow.DesktopEditing.cs @@ -0,0 +1,622 @@ +using System; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Threading; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.DesktopEditing; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; +using LanMountainDesktop.Theme; + +namespace LanMountainDesktop.Views; + +public partial class MainWindow +{ + private static readonly TimeSpan DesktopEditOverlayAnimationDuration = FluttermotionToken.Fast; + + private DesktopEditSession _desktopEditSession; + private DesktopEditOverlayPresenter? _desktopEditOverlayPresenter; + private Border? _desktopEditSourceHost; + private Rect _desktopEditOriginalRect; + private int _desktopEditStartRow; + private int _desktopEditStartColumn; + private int _desktopEditStartWidthCells; + private int _desktopEditStartHeightCells; + private int _desktopEditMinWidthCells; + private int _desktopEditMinHeightCells; + private int _desktopEditMaxWidthCells; + private int _desktopEditMaxHeightCells; + private DesktopComponentResizeMode _desktopEditResizeMode; + private int _desktopEditOverlayVersion; + private int _desktopEditCommitVersion; + private bool _isDesktopEditCommitPending; + + private bool HasActiveDesktopEditSession => _desktopEditSession.IsActive || _isDesktopEditCommitPending; + + private bool IsDesktopEditDragMode => + _desktopEditSession.Mode is DesktopEditSessionMode.PendingNew or DesktopEditSessionMode.DraggingNew or DesktopEditSessionMode.DraggingExisting; + + private bool IsDesktopEditResizeMode => + _desktopEditSession.Mode == DesktopEditSessionMode.ResizingExisting; + + private void EnsureDesktopEditOverlayPresenter() + { + if (DesktopEditDragLayer is null) + { + return; + } + + _desktopEditOverlayPresenter ??= new DesktopEditOverlayPresenter(); + if (!DesktopEditDragLayer.Children.Contains(_desktopEditOverlayPresenter.Root)) + { + DesktopEditDragLayer.Children.Clear(); + DesktopEditDragLayer.Children.Add(_desktopEditOverlayPresenter.Root); + } + + UpdateDesktopEditOverlayViewportSize(); + } + + private void UpdateDesktopEditOverlayViewportSize() + { + if (_desktopEditOverlayPresenter is null) + { + return; + } + + var width = Math.Max( + DesktopPagesViewport?.Bounds.Width ?? 0, + DesktopEditDragLayer?.Bounds.Width ?? 0); + var height = Math.Max( + DesktopPagesViewport?.Bounds.Height ?? 0, + DesktopEditDragLayer?.Bounds.Height ?? 0); + if (width <= 0 || height <= 0) + { + return; + } + + _desktopEditOverlayPresenter.SetViewportSize(new Size(width, height)); + } + + private bool TryGetCurrentDesktopGridGeometry(out DesktopGridGeometry geometry) + { + geometry = default; + if (_currentDesktopCellSize <= 0 || + _currentDesktopSurfaceIndex < 0 || + _currentDesktopSurfaceIndex >= _desktopPageCount || + !_desktopPageComponentGrids.TryGetValue(_currentDesktopSurfaceIndex, out var pageGrid)) + { + return false; + } + + var columnCount = pageGrid.ColumnDefinitions.Count; + var rowCount = pageGrid.RowDefinitions.Count; + if (columnCount <= 0 || rowCount <= 0) + { + return false; + } + + geometry = new DesktopGridGeometry( + Origin: default, + CellSize: _currentDesktopCellSize, + CellGap: _currentDesktopCellGap, + ColumnCount: columnCount, + RowCount: rowCount); + return geometry.IsValid; + } + + private Rect? GetComponentLibraryBoundsInViewport() + { + if (!_isComponentLibraryOpen || + ComponentLibraryWindow is null || + DesktopPagesViewport is null || + !ComponentLibraryWindow.IsVisible || + ComponentLibraryWindow.Bounds.Width <= 0 || + ComponentLibraryWindow.Bounds.Height <= 0) + { + return null; + } + + var origin = ComponentLibraryWindow.TranslatePoint(default, DesktopPagesViewport); + return origin.HasValue + ? new Rect(origin.Value, ComponentLibraryWindow.Bounds.Size) + : null; + } + + private static Size GetComponentPixelSize(int widthCells, int heightCells, double cellSize, double cellGap) + { + var safeWidthCells = Math.Max(1, widthCells); + var safeHeightCells = Math.Max(1, heightCells); + return new Size( + safeWidthCells * cellSize + Math.Max(0, safeWidthCells - 1) * cellGap, + safeHeightCells * cellSize + Math.Max(0, safeHeightCells - 1) * cellGap); + } + + private string ResolveDesktopEditTitle(string componentId) + { + return _componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor) + ? descriptor.Definition.DisplayName + : componentId; + } + + private void UpdateDesktopEditOverlayMetadata(string componentId, int widthCells, int heightCells, string? detail) + { + EnsureDesktopEditOverlayPresenter(); + _desktopEditOverlayPresenter?.UpdateGhostContent( + ResolveDesktopEditTitle(componentId), + detail, + $"{Math.Max(1, widthCells)}x{Math.Max(1, heightCells)}"); + } + + private bool TryGetDesktopPlacementById(string? placementId, out DesktopComponentPlacementSnapshot placement) + { + placement = null!; + if (string.IsNullOrWhiteSpace(placementId)) + { + return false; + } + + var matched = _desktopComponentPlacements.FirstOrDefault(candidate => + string.Equals(candidate.PlacementId, placementId, StringComparison.OrdinalIgnoreCase)); + if (matched is null) + { + return false; + } + + placement = matched; + return true; + } + + private void SetDesktopEditSourceHost(Border? host, double opacity) + { + _desktopEditSourceHost = host; + if (_desktopEditSourceHost is not null) + { + _desktopEditSourceHost.Opacity = opacity; + } + } + + private void RestoreDesktopEditSourceHost() + { + if (_desktopEditSourceHost is null) + { + return; + } + + _desktopEditSourceHost.Opacity = 1; + ApplyDesktopEditStateToHost(_desktopEditSourceHost, _isComponentLibraryOpen); + _desktopEditSourceHost = null; + } + + private void ResetDesktopEditState() + { + RestoreDesktopEditSourceHost(); + _desktopEditSession = default; + _desktopEditOriginalRect = default; + _desktopEditStartRow = 0; + _desktopEditStartColumn = 0; + _desktopEditStartWidthCells = 0; + _desktopEditStartHeightCells = 0; + _desktopEditMinWidthCells = 0; + _desktopEditMinHeightCells = 0; + _desktopEditMaxWidthCells = 0; + _desktopEditMaxHeightCells = 0; + _desktopEditResizeMode = DesktopComponentResizeMode.Proportional; + _isDesktopEditCommitPending = false; + + if (_desktopEditOverlayPresenter is not null) + { + _desktopEditOverlayPresenter.SetCandidateRect(null); + _desktopEditOverlayPresenter.Hide(); + } + } + + private void CancelDesktopEditSession(bool animate) + { + if (_isDesktopEditCommitPending) + { + _desktopEditCommitVersion++; + ResetDesktopEditState(); + return; + } + + if (!_desktopEditSession.IsActive) + { + ResetDesktopEditState(); + return; + } + + var version = ++_desktopEditOverlayVersion; + if (animate && _desktopEditOverlayPresenter is not null) + { + _desktopEditOverlayPresenter.Cancel(); + DispatcherTimer.RunOnce( + () => + { + if (version != _desktopEditOverlayVersion) + { + return; + } + + ResetDesktopEditState(); + }, + DesktopEditOverlayAnimationDuration); + return; + } + + ResetDesktopEditState(); + } + + private bool CanCommitDesktopEditAtRect(Rect finalRect) + { + return DesktopPlacementMath.CanCommitPlacement(finalRect, GetComponentLibraryBoundsInViewport()); + } + + private void RunDesktopEditCommit(Rect finalRect, Action commitAction) + { + _isDesktopEditCommitPending = true; + var overlayVersion = ++_desktopEditOverlayVersion; + var scheduledCommitVersion = ++_desktopEditCommitVersion; + _desktopEditOverlayPresenter?.Commit(); + DispatcherTimer.RunOnce( + () => + { + if (overlayVersion != _desktopEditOverlayVersion || + !DesktopEditCommitMath.IsPendingCommitValid( + _isDesktopEditCommitPending, + scheduledCommitVersion, + _desktopEditCommitVersion)) + { + return; + } + + if (!CanCommitDesktopEditAtRect(finalRect)) + { + ResetDesktopEditState(); + return; + } + + commitAction(); + ResetDesktopEditState(); + }, + DesktopEditOverlayAnimationDuration); + } + + private void UpdateDesktopEditSession(Point pointerInViewport) + { + if (_isDesktopEditCommitPending || !_desktopEditSession.IsActive) + { + return; + } + + _desktopEditSession = _desktopEditSession + .WithCurrentPointer(pointerInViewport) + .WithComponentLibraryBounds(GetComponentLibraryBoundsInViewport()); + + switch (_desktopEditSession.Mode) + { + case DesktopEditSessionMode.PendingNew: + PromotePendingNewDesktopEditIfNeeded(); + break; + case DesktopEditSessionMode.DraggingNew: + case DesktopEditSessionMode.DraggingExisting: + UpdateActiveDesktopDragPreview(); + break; + case DesktopEditSessionMode.ResizingExisting: + UpdateActiveDesktopResizePreview(); + break; + } + } + + private void PromotePendingNewDesktopEditIfNeeded() + { + var threshold = DesktopPlacementMath.ComputeDragStartThreshold(_currentDesktopCellSize); + if (!_desktopEditSession.HasExceededThreshold(threshold) || + _desktopEditSession.IsPointerInsideComponentLibrary()) + { + return; + } + + _desktopEditSession = _desktopEditSession.PromoteToDraggingNew(); + EnsureDesktopEditOverlayPresenter(); + _desktopEditOverlayPresenter?.Show(); + UpdateActiveDesktopDragPreview(); + } + + private void UpdateActiveDesktopDragPreview() + { + if (_desktopEditSession.Mode is not (DesktopEditSessionMode.DraggingNew or DesktopEditSessionMode.DraggingExisting) || + !TryGetCurrentDesktopGridGeometry(out var grid) || + DesktopPagesViewport is null) + { + return; + } + + EnsureDesktopEditOverlayPresenter(); + + var previewSize = GetComponentPixelSize( + _desktopEditSession.WidthCells, + _desktopEditSession.HeightCells, + _currentDesktopCellSize, + _currentDesktopCellGap); + var previewOrigin = DesktopPlacementMath.Subtract( + _desktopEditSession.CurrentPointerInViewport, + _desktopEditSession.PointerOffsetInViewport); + var previewRect = new Rect(previewOrigin, previewSize); + var hasSnap = DesktopPlacementMath.TryGetSnappedCell( + grid, + _desktopEditSession.CurrentPointerInViewport, + _desktopEditSession.PointerOffsetInViewport, + _desktopEditSession.WidthCells, + _desktopEditSession.HeightCells, + out var column, + out var row); + var snappedRect = hasSnap + ? DesktopPlacementMath.GetCellRect(grid, column, row, _desktopEditSession.WidthCells, _desktopEditSession.HeightCells) + : default; + var withinViewport = + _desktopEditSession.CurrentPointerInViewport.X >= 0 && + _desktopEditSession.CurrentPointerInViewport.Y >= 0 && + _desktopEditSession.CurrentPointerInViewport.X <= DesktopPagesViewport.Bounds.Width && + _desktopEditSession.CurrentPointerInViewport.Y <= DesktopPagesViewport.Bounds.Height; + var occludedByLibrary = + _desktopEditSession.IsPointerInsideComponentLibrary() || + _desktopEditSession.IsPreviewOccludedByComponentLibrary(previewRect); + var canDrop = withinViewport && hasSnap && !occludedByLibrary; + + _desktopEditSession = canDrop + ? _desktopEditSession.WithTargetCell(row, column) + : _desktopEditSession with { TargetRow = -1, TargetColumn = -1 }; + + _desktopEditOverlayPresenter?.SetPreviewRect(previewRect); + _desktopEditOverlayPresenter?.SetCandidateRect(canDrop ? snappedRect : null); + _desktopEditOverlayPresenter?.SetInvalid(!canDrop); + } + + private void UpdateActiveDesktopResizePreview() + { + if (_desktopEditSession.Mode != DesktopEditSessionMode.ResizingExisting || + !TryGetCurrentDesktopGridGeometry(out var grid) || + !TryGetDesktopPlacementById(_desktopEditSession.PlacementId, out var placement)) + { + return; + } + + EnsureDesktopEditOverlayPresenter(); + + var deltaX = _desktopEditSession.CurrentPointerInViewport.X - _desktopEditSession.StartPointerInViewport.X; + var deltaY = _desktopEditSession.CurrentPointerInViewport.Y - _desktopEditSession.StartPointerInViewport.Y; + + var minSize = GetComponentPixelSize( + _desktopEditMinWidthCells, + _desktopEditMinHeightCells, + _currentDesktopCellSize, + _currentDesktopCellGap); + var maxSize = GetComponentPixelSize( + _desktopEditMaxWidthCells, + _desktopEditMaxHeightCells, + _currentDesktopCellSize, + _currentDesktopCellGap); + + double previewWidth; + double previewHeight; + int widthCells; + int heightCells; + + if (_desktopEditResizeMode == DesktopComponentResizeMode.Free) + { + previewWidth = Math.Clamp(_desktopEditOriginalRect.Width + deltaX, minSize.Width, maxSize.Width); + previewHeight = Math.Clamp(_desktopEditOriginalRect.Height + deltaY, minSize.Height, maxSize.Height); + widthCells = Math.Clamp( + (int)Math.Round(_desktopEditStartWidthCells + deltaX / CurrentDesktopPitch), + _desktopEditMinWidthCells, + _desktopEditMaxWidthCells); + heightCells = Math.Clamp( + (int)Math.Round(_desktopEditStartHeightCells + deltaY / CurrentDesktopPitch), + _desktopEditMinHeightCells, + _desktopEditMaxHeightCells); + } + else + { + var widthScale = (_desktopEditOriginalRect.Width + deltaX) / Math.Max(1, _desktopEditOriginalRect.Width); + var heightScale = (_desktopEditOriginalRect.Height + deltaY) / Math.Max(1, _desktopEditOriginalRect.Height); + var proposedScale = Math.Max(widthScale, heightScale); + var minScale = Math.Max( + (double)_desktopEditMinWidthCells / Math.Max(1, _desktopEditStartWidthCells), + (double)_desktopEditMinHeightCells / Math.Max(1, _desktopEditStartHeightCells)); + var maxScale = Math.Min( + (double)_desktopEditMaxWidthCells / Math.Max(1, _desktopEditStartWidthCells), + (double)_desktopEditMaxHeightCells / Math.Max(1, _desktopEditStartHeightCells)); + + if (double.IsNaN(proposedScale) || double.IsInfinity(proposedScale)) + { + proposedScale = minScale; + } + + if (maxScale < minScale) + { + maxScale = minScale; + } + + var scale = Math.Clamp(proposedScale, minScale, maxScale); + previewWidth = Math.Clamp(_desktopEditOriginalRect.Width * scale, minSize.Width, maxSize.Width); + previewHeight = Math.Clamp(_desktopEditOriginalRect.Height * scale, minSize.Height, maxSize.Height); + widthCells = Math.Clamp( + (int)Math.Round(_desktopEditStartWidthCells * scale), + _desktopEditMinWidthCells, + _desktopEditMaxWidthCells); + heightCells = Math.Clamp( + (int)Math.Round(_desktopEditStartHeightCells * scale), + _desktopEditMinHeightCells, + _desktopEditMaxHeightCells); + } + + var normalized = NormalizeComponentCellSpan(_desktopEditSession.ComponentId ?? string.Empty, (widthCells, heightCells)); + widthCells = Math.Clamp(normalized.WidthCells, _desktopEditMinWidthCells, _desktopEditMaxWidthCells); + heightCells = Math.Clamp(normalized.HeightCells, _desktopEditMinHeightCells, _desktopEditMaxHeightCells); + + var previewRect = new Rect(_desktopEditOriginalRect.X, _desktopEditOriginalRect.Y, previewWidth, previewHeight); + var snappedRect = DesktopPlacementMath.GetCellRect(grid, placement.Column, placement.Row, widthCells, heightCells); + var occludedByLibrary = + _desktopEditSession.IsPointerInsideComponentLibrary() || + DesktopPlacementMath.IsOccludedByComponentLibrary(previewRect, _desktopEditSession.ComponentLibraryBounds); + var canCommit = !occludedByLibrary; + + _desktopEditSession = (_desktopEditSession with + { + WidthCells = widthCells, + HeightCells = heightCells, + TargetRow = canCommit ? placement.Row : -1, + TargetColumn = canCommit ? placement.Column : -1 + }).WithComponentLibraryBounds(GetComponentLibraryBoundsInViewport()); + + UpdateDesktopEditOverlayMetadata( + _desktopEditSession.ComponentId ?? placement.ComponentId, + widthCells, + heightCells, + L("component.resize", "Resize")); + _desktopEditOverlayPresenter?.SetPreviewRect(previewRect); + _desktopEditOverlayPresenter?.SetCandidateRect(canCommit ? snappedRect : null); + _desktopEditOverlayPresenter?.SetInvalid(!canCommit); + } + + private bool CompleteDesktopEditSession(Point pointerInViewport) + { + if (_isDesktopEditCommitPending || !_desktopEditSession.IsActive) + { + return false; + } + + UpdateDesktopEditSession(pointerInViewport); + + switch (_desktopEditSession.Mode) + { + case DesktopEditSessionMode.DraggingNew: + return CompleteNewDesktopComponentDrag(); + case DesktopEditSessionMode.DraggingExisting: + return CompleteExistingDesktopComponentMove(); + case DesktopEditSessionMode.ResizingExisting: + return CompleteExistingDesktopComponentResize(); + default: + return false; + } + } + + private bool CompleteNewDesktopComponentDrag() + { + if (!_desktopEditSession.HasTargetCell || + string.IsNullOrWhiteSpace(_desktopEditSession.ComponentId) || + !TryGetCurrentDesktopGridGeometry(out var grid)) + { + return false; + } + + var finalRect = _desktopEditSession.GetPreviewRect(grid); + if (!CanCommitDesktopEditAtRect(finalRect)) + { + return false; + } + + _desktopEditOverlayPresenter?.SetPreviewRect(finalRect); + _desktopEditOverlayPresenter?.SetCandidateRect(finalRect); + _desktopEditOverlayPresenter?.SetInvalid(false); + + var componentId = _desktopEditSession.ComponentId; + var pageIndex = _desktopEditSession.PageIndex; + var row = _desktopEditSession.TargetRow; + var column = _desktopEditSession.TargetColumn; + RunDesktopEditCommit(finalRect, () => PlaceDesktopComponentOnPage(componentId, pageIndex, row, column)); + return true; + } + + private bool CompleteExistingDesktopComponentMove() + { + if (!_desktopEditSession.HasTargetCell || + string.IsNullOrWhiteSpace(_desktopEditSession.PlacementId) || + !TryGetCurrentDesktopGridGeometry(out var grid)) + { + return false; + } + + var finalRect = _desktopEditSession.GetPreviewRect(grid); + if (!CanCommitDesktopEditAtRect(finalRect)) + { + return false; + } + + _desktopEditOverlayPresenter?.SetPreviewRect(finalRect); + _desktopEditOverlayPresenter?.SetCandidateRect(finalRect); + _desktopEditOverlayPresenter?.SetInvalid(false); + + var placementId = _desktopEditSession.PlacementId; + var row = _desktopEditSession.TargetRow; + var column = _desktopEditSession.TargetColumn; + if (!DesktopPlacementMath.HasCellPositionChanged(_desktopEditStartRow, _desktopEditStartColumn, row, column)) + { + return false; + } + + RunDesktopEditCommit(finalRect, () => TryMoveExistingDesktopComponent(placementId, row, column)); + return true; + } + + private bool CompleteExistingDesktopComponentResize() + { + if (!_desktopEditSession.HasTargetCell || + string.IsNullOrWhiteSpace(_desktopEditSession.PlacementId) || + !TryGetCurrentDesktopGridGeometry(out var grid)) + { + return false; + } + + var finalRect = _desktopEditSession.GetPreviewRect(grid); + if (!CanCommitDesktopEditAtRect(finalRect)) + { + return false; + } + + _desktopEditOverlayPresenter?.SetPreviewRect(finalRect); + _desktopEditOverlayPresenter?.SetCandidateRect(finalRect); + _desktopEditOverlayPresenter?.SetInvalid(false); + + var placementId = _desktopEditSession.PlacementId; + var widthCells = Math.Max(1, _desktopEditSession.WidthCells); + var heightCells = Math.Max(1, _desktopEditSession.HeightCells); + if (!DesktopPlacementMath.HasCellSpanChanged(_desktopEditStartWidthCells, _desktopEditStartHeightCells, widthCells, heightCells)) + { + return false; + } + + RunDesktopEditCommit(finalRect, () => ApplyExistingDesktopComponentResize(placementId, widthCells, heightCells)); + return true; + } + + private void ApplyExistingDesktopComponentResize(string placementId, int widthCells, int heightCells) + { + if (!TryGetDesktopPlacementById(placementId, out var placement)) + { + return; + } + + var before = ClonePlacementSnapshot(placement); + var changed = placement.WidthCells != widthCells || placement.HeightCells != heightCells; + placement.WidthCells = widthCells; + placement.HeightCells = heightCells; + + if (_desktopEditSourceHost is not null) + { + Grid.SetColumnSpan(_desktopEditSourceHost, widthCells); + Grid.SetRowSpan(_desktopEditSourceHost, heightCells); + ApplyDesktopEditStateToHost(_desktopEditSourceHost, _isComponentLibraryOpen); + } + + if (!changed) + { + return; + } + + PersistSettings(); + TelemetryServices.Usage?.TrackDesktopComponentResized(before, ClonePlacementSnapshot(placement), "component.resize"); + } +} diff --git a/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs b/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs index 621eb9d..b7a4519 100644 --- a/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs +++ b/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs @@ -200,6 +200,7 @@ public partial class MainWindow { DesktopEditDragLayer.Width = pageWidth; DesktopEditDragLayer.Height = pageHeight; + UpdateDesktopEditOverlayViewportSize(); } DesktopPagesHost.RowDefinitions.Clear(); @@ -486,8 +487,7 @@ public partial class MainWindow { return !_isSettingsOpen && !_isComponentLibraryOpen && - !_isDesktopComponentDragActive && - !_isDesktopComponentResizeActive && + !HasActiveDesktopEditSession && _desktopSurfacePageWidth > 1; }