mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
0.3.11
噪音数据历史记录,引入数据库
This commit is contained in:
@@ -184,6 +184,11 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
"component.study_session_control",
|
||||
() => new StudySessionControlWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.36, 10, 24)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopStudySessionHistory,
|
||||
"component.study_session_history",
|
||||
() => new StudySessionHistoryWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.34, 10, 24)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopStudyNoiseCurve,
|
||||
"component.study_noise_curve",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="420"
|
||||
d:DesignHeight="220"
|
||||
x:Class="LanMountainDesktop.Views.Components.StudySessionHistoryWidget">
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="glass-strong"
|
||||
CornerRadius="22"
|
||||
Padding="12,10"
|
||||
ClipToBounds="True">
|
||||
<Grid>
|
||||
<Grid x:Name="ContentRootGrid"
|
||||
RowDefinitions="Auto,*,Auto"
|
||||
RowSpacing="8">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Grid.Row="0"
|
||||
Text="Session History"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<Border Grid.Row="1"
|
||||
CornerRadius="10"
|
||||
Background="#1AFFFFFF"
|
||||
BorderBrush="#26FFFFFF"
|
||||
BorderThickness="1"
|
||||
Padding="6"
|
||||
ClipToBounds="True">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel x:Name="SessionListPanel"
|
||||
Spacing="6" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
Grid.Row="2"
|
||||
Text="No session history"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="DialogOverlayBorder"
|
||||
IsVisible="False"
|
||||
Background="#70000000"
|
||||
Padding="12"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<Border x:Name="DialogCardBorder"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center"
|
||||
CornerRadius="12"
|
||||
BorderThickness="1"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock x:Name="DialogTitleTextBlock"
|
||||
Text="Dialog Title"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock x:Name="DialogMessageTextBlock"
|
||||
Text="Dialog message"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBox x:Name="DialogRenameTextBox"
|
||||
IsVisible="False"
|
||||
Watermark="Enter session name"
|
||||
MinWidth="120"
|
||||
VerticalContentAlignment="Center" />
|
||||
<Grid ColumnDefinitions="*,*"
|
||||
ColumnSpacing="8">
|
||||
<Button x:Name="DialogCancelButton"
|
||||
Grid.Column="0"
|
||||
Content="Cancel"
|
||||
CornerRadius="8"
|
||||
Height="30" />
|
||||
<Button x:Name="DialogConfirmButton"
|
||||
Grid.Column="1"
|
||||
Content="Confirm"
|
||||
CornerRadius="8"
|
||||
Height="30" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,738 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using FluentIcons.Avalonia;
|
||||
using FluentIcons.Common;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
|
||||
{
|
||||
private const double MinTextContrast = 4.5;
|
||||
private enum HistoryDialogMode
|
||||
{
|
||||
None = 0,
|
||||
Rename = 1,
|
||||
Delete = 2
|
||||
}
|
||||
|
||||
private static readonly Color[] PrimaryColorCandidates =
|
||||
{
|
||||
Color.Parse("#FFF8FAFC"),
|
||||
Color.Parse("#FFEAF3FF"),
|
||||
Color.Parse("#FF101C2A"),
|
||||
Color.Parse("#FF1B2E45"),
|
||||
Color.Parse("#FFFFFFFF")
|
||||
};
|
||||
|
||||
private static readonly Color[] SecondaryColorCandidates =
|
||||
{
|
||||
Color.Parse("#FFDDE7F3"),
|
||||
Color.Parse("#FFCBD9EA"),
|
||||
Color.Parse("#FF24384F"),
|
||||
Color.Parse("#FF2F4763"),
|
||||
Color.Parse("#FF0F1D2D")
|
||||
};
|
||||
|
||||
private static readonly Color DarkSubstrate = Color.Parse("#FF0B1220");
|
||||
private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA");
|
||||
|
||||
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
|
||||
private double _currentCellSize = 48;
|
||||
private string _languageCode = "zh-CN";
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isSubscribed;
|
||||
private bool _isCompactMode;
|
||||
private bool _isUltraCompactMode;
|
||||
private string? _loadingSessionId;
|
||||
private HistoryDialogMode _dialogMode;
|
||||
private string? _dialogSessionId;
|
||||
private string _dialogSessionLabel = string.Empty;
|
||||
private StudyAnalyticsSnapshot? _currentSnapshot;
|
||||
private string? _transientStatus;
|
||||
private DateTimeOffset _transientStatusExpireAt;
|
||||
|
||||
public StudySessionHistoryWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
DialogCancelButton.Click += (_, _) => CloseDialog();
|
||||
DialogConfirmButton.Click += (_, _) => ConfirmDialog();
|
||||
DialogRenameTextBox.KeyDown += OnDialogRenameTextBoxKeyDown;
|
||||
|
||||
ReloadLanguageCode();
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
UpdateAdaptiveLayout();
|
||||
if (_currentSnapshot is not null)
|
||||
{
|
||||
RenderSnapshot(_currentSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
_isOnActivePage = isOnActivePage;
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
RefreshFromService();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ReloadLanguageCode();
|
||||
|
||||
if (!_isSubscribed)
|
||||
{
|
||||
_studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated;
|
||||
_isSubscribed = true;
|
||||
}
|
||||
|
||||
RefreshFromService();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
if (_isSubscribed)
|
||||
{
|
||||
_studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
|
||||
_isSubscribed = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
UpdateAdaptiveLayout();
|
||||
if (_currentSnapshot is not null)
|
||||
{
|
||||
RenderSnapshot(_currentSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (!_isAttached)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_loadingSessionId) &&
|
||||
string.Equals(e.Snapshot.SelectedSessionReportId, _loadingSessionId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_loadingSessionId = null;
|
||||
SetTransientStatus(L("study.session_history.loaded", "Data loaded"), 1.5);
|
||||
}
|
||||
|
||||
_currentSnapshot = e.Snapshot;
|
||||
if (_isOnActivePage)
|
||||
{
|
||||
RenderSnapshot(e.Snapshot);
|
||||
}
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private void RefreshFromService()
|
||||
{
|
||||
_currentSnapshot = _studyAnalyticsService.GetSnapshot();
|
||||
RenderSnapshot(_currentSnapshot);
|
||||
}
|
||||
|
||||
private void RenderSnapshot(StudyAnalyticsSnapshot snapshot)
|
||||
{
|
||||
var panelColor = ResolvePanelBackgroundColor();
|
||||
var panelSamples = BuildPanelBackgroundSamples(panelColor);
|
||||
TitleTextBlock.Text = L("study.session_history.title", "Session History");
|
||||
TitleTextBlock.Foreground = CreateAdaptiveBrush(panelSamples, PrimaryColorCandidates, MinTextContrast);
|
||||
|
||||
if (_transientStatus is not null && DateTimeOffset.UtcNow > _transientStatusExpireAt)
|
||||
{
|
||||
_transientStatus = null;
|
||||
}
|
||||
|
||||
SessionListPanel.Children.Clear();
|
||||
var history = snapshot.SessionHistory;
|
||||
if (history.Count == 0)
|
||||
{
|
||||
if (_dialogMode != HistoryDialogMode.None)
|
||||
{
|
||||
CloseDialog();
|
||||
}
|
||||
|
||||
StatusTextBlock.Text = _transientStatus ?? L("study.session_history.empty", "No session history");
|
||||
StatusTextBlock.Foreground = CreateAdaptiveBrush(panelSamples, SecondaryColorCandidates, MinTextContrast);
|
||||
UpdateDialogVisual(snapshot, panelColor);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_dialogSessionId))
|
||||
{
|
||||
var dialogEntry = FindHistoryEntry(history, _dialogSessionId);
|
||||
if (dialogEntry is null)
|
||||
{
|
||||
CloseDialog();
|
||||
}
|
||||
else
|
||||
{
|
||||
_dialogSessionLabel = dialogEntry.Label;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entry in history)
|
||||
{
|
||||
SessionListPanel.Children.Add(CreateSessionRow(entry, snapshot.SelectedSessionReportId, panelColor));
|
||||
}
|
||||
|
||||
StatusTextBlock.Text = _transientStatus ?? string.Empty;
|
||||
StatusTextBlock.Foreground = CreateAdaptiveBrush(panelSamples, SecondaryColorCandidates, MinTextContrast);
|
||||
UpdateDialogVisual(snapshot, panelColor);
|
||||
}
|
||||
|
||||
private Control CreateSessionRow(StudySessionHistoryEntry entry, string? selectedSessionId, Color panelColor)
|
||||
{
|
||||
var isSelected = string.Equals(selectedSessionId, entry.SessionId, StringComparison.OrdinalIgnoreCase);
|
||||
var isLoading = string.Equals(_loadingSessionId, entry.SessionId, StringComparison.OrdinalIgnoreCase);
|
||||
var isDialogOpen = _dialogMode != HistoryDialogMode.None;
|
||||
|
||||
var rowBackground = isSelected
|
||||
? Color.Parse("#4A5FA9FF")
|
||||
: Color.Parse("#2CFFFFFF");
|
||||
var rowBorderColor = isSelected
|
||||
? Color.Parse("#99C7E0FF")
|
||||
: Color.Parse("#33FFFFFF");
|
||||
|
||||
var rowBorder = new Border
|
||||
{
|
||||
CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.20, 8, 14)),
|
||||
Background = new SolidColorBrush(rowBackground),
|
||||
BorderBrush = new SolidColorBrush(rowBorderColor),
|
||||
BorderThickness = new Thickness(1),
|
||||
Padding = new Thickness(Math.Clamp(8, 6, 12), Math.Clamp(6, 4, 10))
|
||||
};
|
||||
|
||||
var panelComposite = ToOpaqueAgainst(panelColor, DarkSubstrate);
|
||||
var rowComposite = ToOpaqueAgainst(rowBackground, panelComposite);
|
||||
var rowPrimaryBrush = CreateAdaptiveBrush(new[] { rowComposite }, PrimaryColorCandidates, MinTextContrast);
|
||||
var rowSecondaryBrush = CreateAdaptiveBrush(new[] { rowComposite }, SecondaryColorCandidates, MinTextContrast);
|
||||
|
||||
var rowGrid = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("*,Auto,Auto,Auto"),
|
||||
ColumnSpacing = _isUltraCompactMode ? 4 : 6
|
||||
};
|
||||
|
||||
var textStack = new StackPanel
|
||||
{
|
||||
Spacing = _isUltraCompactMode ? 0 : 2
|
||||
};
|
||||
textStack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = entry.Label,
|
||||
FontSize = Math.Clamp(12 * (_isCompactMode ? 0.92 : 1.0), 10, 17),
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
MaxLines = 1,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
Foreground = rowPrimaryBrush
|
||||
});
|
||||
|
||||
if (!_isUltraCompactMode)
|
||||
{
|
||||
var metaText = isLoading
|
||||
? L("study.session_history.loading", "Loading data...")
|
||||
: string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
L("study.session_history.meta_format", "{0} · Avg {1:F1}"),
|
||||
FormatDuration(entry.Duration),
|
||||
entry.AverageScore);
|
||||
|
||||
textStack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = metaText,
|
||||
FontSize = Math.Clamp(10.5 * (_isCompactMode ? 0.94 : 1.0), 9, 14),
|
||||
MaxLines = 1,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
Foreground = rowSecondaryBrush
|
||||
});
|
||||
}
|
||||
rowGrid.Children.Add(textStack);
|
||||
|
||||
var playButton = CreateActionIconButton(
|
||||
L("study.session_history.action.view", "View"),
|
||||
Symbol.Play,
|
||||
isLoading || isDialogOpen,
|
||||
isSelected ? Color.Parse("#5A7FDEFF") : Color.Parse("#3D649FFF"),
|
||||
rowComposite,
|
||||
() => SelectReport(entry.SessionId),
|
||||
IconVariant.Filled);
|
||||
Grid.SetColumn(playButton, 1);
|
||||
rowGrid.Children.Add(playButton);
|
||||
|
||||
var renameButton = CreateActionIconButton(
|
||||
L("study.session_history.action.rename", "Rename"),
|
||||
Symbol.Edit,
|
||||
isLoading || isDialogOpen,
|
||||
Color.Parse("#2BFFFFFF"),
|
||||
rowComposite,
|
||||
() => ShowRenameDialog(entry.SessionId, entry.Label));
|
||||
Grid.SetColumn(renameButton, 2);
|
||||
rowGrid.Children.Add(renameButton);
|
||||
|
||||
var deleteButton = CreateActionIconButton(
|
||||
L("study.session_history.action.delete", "Delete"),
|
||||
Symbol.Delete,
|
||||
isLoading || isDialogOpen,
|
||||
Color.Parse("#5AC74E58"),
|
||||
rowComposite,
|
||||
() => ShowDeleteDialog(entry.SessionId, entry.Label));
|
||||
Grid.SetColumn(deleteButton, 3);
|
||||
rowGrid.Children.Add(deleteButton);
|
||||
|
||||
rowBorder.Child = rowGrid;
|
||||
return rowBorder;
|
||||
}
|
||||
|
||||
private Button CreateActionIconButton(
|
||||
string tooltip,
|
||||
Symbol symbol,
|
||||
bool isDisabled,
|
||||
Color buttonBackground,
|
||||
Color rowComposite,
|
||||
Action onClick,
|
||||
IconVariant iconVariant = IconVariant.Regular)
|
||||
{
|
||||
var buttonComposite = ToOpaqueAgainst(buttonBackground, rowComposite);
|
||||
var iconBrush = CreateAdaptiveBrush(new[] { buttonComposite }, PrimaryColorCandidates, MinTextContrast);
|
||||
|
||||
var iconSize = Math.Clamp(13 * (_isCompactMode ? 0.92 : 1.0), 11, 17);
|
||||
var icon = new SymbolIcon
|
||||
{
|
||||
Symbol = symbol,
|
||||
IconVariant = iconVariant,
|
||||
FontSize = iconSize,
|
||||
Width = iconSize,
|
||||
Height = iconSize,
|
||||
Foreground = iconBrush,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
MinWidth = _isUltraCompactMode ? 26 : 34,
|
||||
Width = _isUltraCompactMode ? 26 : 34,
|
||||
Height = Math.Clamp(26 * (_isCompactMode ? 0.90 : 1.0), 24, 30),
|
||||
Padding = new Thickness(0),
|
||||
CornerRadius = new CornerRadius(10),
|
||||
Background = new SolidColorBrush(buttonBackground),
|
||||
BorderBrush = Brushes.Transparent,
|
||||
BorderThickness = new Thickness(0),
|
||||
Content = icon,
|
||||
IsEnabled = !isDisabled
|
||||
};
|
||||
button.Classes.Add("study-history-action-button");
|
||||
ToolTip.SetTip(button, tooltip);
|
||||
button.Click += (_, _) => onClick();
|
||||
return button;
|
||||
}
|
||||
|
||||
private void SelectReport(string sessionId)
|
||||
{
|
||||
CloseDialog();
|
||||
|
||||
_loadingSessionId = sessionId;
|
||||
SetTransientStatus(L("study.session_history.loading", "Loading data..."), 4);
|
||||
if (_currentSnapshot is not null)
|
||||
{
|
||||
RenderSnapshot(_currentSnapshot);
|
||||
}
|
||||
|
||||
if (_studyAnalyticsService.SelectSessionReport(sessionId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_loadingSessionId = null;
|
||||
SetTransientStatus(L("study.session_history.select_failed", "Unable to switch session"));
|
||||
if (_currentSnapshot is not null)
|
||||
{
|
||||
RenderSnapshot(_currentSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowRenameDialog(string sessionId, string label)
|
||||
{
|
||||
_dialogMode = HistoryDialogMode.Rename;
|
||||
_dialogSessionId = sessionId;
|
||||
_dialogSessionLabel = label;
|
||||
DialogRenameTextBox.Text = label;
|
||||
if (_currentSnapshot is not null)
|
||||
{
|
||||
RenderSnapshot(_currentSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowDeleteDialog(string sessionId, string label)
|
||||
{
|
||||
_dialogMode = HistoryDialogMode.Delete;
|
||||
_dialogSessionId = sessionId;
|
||||
_dialogSessionLabel = label;
|
||||
if (_currentSnapshot is not null)
|
||||
{
|
||||
RenderSnapshot(_currentSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfirmDialog()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_dialogSessionId))
|
||||
{
|
||||
CloseDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_dialogMode == HistoryDialogMode.Rename)
|
||||
{
|
||||
ConfirmRename(_dialogSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_dialogMode == HistoryDialogMode.Delete)
|
||||
{
|
||||
ConfirmDelete(_dialogSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
CloseDialog();
|
||||
}
|
||||
|
||||
private void ConfirmRename(string sessionId)
|
||||
{
|
||||
var nextLabel = (DialogRenameTextBox.Text ?? string.Empty).Trim();
|
||||
if (!_studyAnalyticsService.RenameSessionReport(sessionId, nextLabel))
|
||||
{
|
||||
SetTransientStatus(L("study.session_history.rename_failed", "Unable to rename session"));
|
||||
}
|
||||
else
|
||||
{
|
||||
SetTransientStatus(L("study.session_history.loaded", "Data loaded"), 1.2);
|
||||
}
|
||||
|
||||
CloseDialog();
|
||||
}
|
||||
|
||||
private void ConfirmDelete(string sessionId)
|
||||
{
|
||||
if (!_studyAnalyticsService.DeleteSessionReport(sessionId))
|
||||
{
|
||||
SetTransientStatus(L("study.session_history.delete_failed", "Unable to delete session"));
|
||||
}
|
||||
else
|
||||
{
|
||||
SetTransientStatus(L("study.session_history.loaded", "Data loaded"), 1.2);
|
||||
}
|
||||
|
||||
CloseDialog();
|
||||
}
|
||||
|
||||
private void CloseDialog()
|
||||
{
|
||||
_dialogMode = HistoryDialogMode.None;
|
||||
_dialogSessionId = null;
|
||||
_dialogSessionLabel = string.Empty;
|
||||
DialogRenameTextBox.Text = string.Empty;
|
||||
}
|
||||
|
||||
private void OnDialogRenameTextBoxKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (_dialogMode != HistoryDialogMode.Rename)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key == Key.Enter)
|
||||
{
|
||||
ConfirmDialog();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == Key.Escape)
|
||||
{
|
||||
CloseDialog();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDialogVisual(StudyAnalyticsSnapshot snapshot, Color panelColor)
|
||||
{
|
||||
var isVisible = _dialogMode != HistoryDialogMode.None && !string.IsNullOrWhiteSpace(_dialogSessionId);
|
||||
DialogOverlayBorder.IsVisible = isVisible;
|
||||
if (!isVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var dialogBackground = Color.Parse("#D92A3E5D");
|
||||
var dialogComposite = ToOpaqueAgainst(dialogBackground, ToOpaqueAgainst(panelColor, DarkSubstrate));
|
||||
DialogCardBorder.Background = new SolidColorBrush(dialogBackground);
|
||||
DialogCardBorder.BorderBrush = new SolidColorBrush(Color.Parse("#66FFFFFF"));
|
||||
DialogTitleTextBlock.Foreground = CreateAdaptiveBrush(new[] { dialogComposite }, PrimaryColorCandidates, MinTextContrast);
|
||||
DialogMessageTextBlock.Foreground = CreateAdaptiveBrush(new[] { dialogComposite }, SecondaryColorCandidates, MinTextContrast);
|
||||
DialogRenameTextBox.Foreground = CreateAdaptiveBrush(new[] { dialogComposite }, PrimaryColorCandidates, MinTextContrast);
|
||||
DialogRenameTextBox.Background = new SolidColorBrush(Color.Parse("#24FFFFFF"));
|
||||
DialogRenameTextBox.BorderBrush = new SolidColorBrush(Color.Parse("#52FFFFFF"));
|
||||
|
||||
var cancelBackground = Color.Parse("#33FFFFFF");
|
||||
var confirmBackground = _dialogMode == HistoryDialogMode.Delete
|
||||
? Color.Parse("#B7504D")
|
||||
: Color.Parse("#4A73CC");
|
||||
|
||||
DialogCancelButton.Background = new SolidColorBrush(cancelBackground);
|
||||
DialogCancelButton.BorderBrush = Brushes.Transparent;
|
||||
DialogCancelButton.BorderThickness = new Thickness(0);
|
||||
DialogCancelButton.Foreground = CreateAdaptiveBrush(
|
||||
new[] { ToOpaqueAgainst(cancelBackground, dialogComposite) },
|
||||
PrimaryColorCandidates,
|
||||
MinTextContrast);
|
||||
|
||||
DialogConfirmButton.Background = new SolidColorBrush(confirmBackground);
|
||||
DialogConfirmButton.BorderBrush = Brushes.Transparent;
|
||||
DialogConfirmButton.BorderThickness = new Thickness(0);
|
||||
DialogConfirmButton.Foreground = CreateAdaptiveBrush(
|
||||
new[] { ToOpaqueAgainst(confirmBackground, dialogComposite) },
|
||||
PrimaryColorCandidates,
|
||||
MinTextContrast);
|
||||
|
||||
var entry = FindHistoryEntry(snapshot.SessionHistory, _dialogSessionId);
|
||||
var label = entry?.Label ?? _dialogSessionLabel;
|
||||
if (_dialogMode == HistoryDialogMode.Rename)
|
||||
{
|
||||
DialogTitleTextBlock.Text = L("study.session_history.dialog.rename_title", "Rename Session");
|
||||
DialogMessageTextBlock.Text = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
L("study.session_history.dialog.rename_message", "Set a new name for \"{0}\"."),
|
||||
label);
|
||||
DialogRenameTextBox.Watermark = L("study.session_history.rename_placeholder", "Enter session name");
|
||||
if (string.IsNullOrWhiteSpace(DialogRenameTextBox.Text))
|
||||
{
|
||||
DialogRenameTextBox.Text = label;
|
||||
}
|
||||
|
||||
DialogRenameTextBox.IsVisible = true;
|
||||
DialogConfirmButton.Content = L("study.session_history.rename_confirm", "Confirm rename");
|
||||
DialogCancelButton.Content = L("study.session_history.rename_cancel", "Cancel rename");
|
||||
}
|
||||
else
|
||||
{
|
||||
DialogTitleTextBlock.Text = L("study.session_history.dialog.delete_title", "Delete Session");
|
||||
DialogMessageTextBlock.Text = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
L("study.session_history.dialog.delete_message", "Delete \"{0}\"? This cannot be undone."),
|
||||
label);
|
||||
DialogRenameTextBox.IsVisible = false;
|
||||
DialogConfirmButton.Content = L("study.session_history.dialog.delete_confirm", "Delete");
|
||||
DialogCancelButton.Content = L("study.session_history.rename_cancel", "Cancel rename");
|
||||
}
|
||||
}
|
||||
|
||||
private void SetTransientStatus(string status, double seconds = 2.2)
|
||||
{
|
||||
_transientStatus = status;
|
||||
_transientStatusExpireAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(0.6, seconds));
|
||||
}
|
||||
|
||||
private void ReloadLanguageCode()
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
}
|
||||
|
||||
private void UpdateAdaptiveLayout()
|
||||
{
|
||||
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.76, 2.2);
|
||||
var widthScale = Bounds.Width > 1 ? Bounds.Width / 360d : cellScale;
|
||||
var heightScale = Bounds.Height > 1 ? Bounds.Height / 180d : cellScale;
|
||||
var scale = Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale) * 1.05), 0.68, 2.2);
|
||||
|
||||
_isCompactMode = scale < 0.92 || (Bounds.Width > 1 && Bounds.Width < 320) || (Bounds.Height > 1 && Bounds.Height < 145);
|
||||
_isUltraCompactMode = scale < 0.78 || (Bounds.Width > 1 && Bounds.Width < 280) || (Bounds.Height > 1 && Bounds.Height < 120);
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.44, 12, 36));
|
||||
RootBorder.Padding = new Thickness(
|
||||
Math.Clamp(12 * scale, 7, 22),
|
||||
Math.Clamp(9 * scale, 5, 16));
|
||||
|
||||
ContentRootGrid.RowSpacing = _isUltraCompactMode
|
||||
? Math.Clamp(4 * scale, 2, 6)
|
||||
: Math.Clamp(7 * scale, 4, 10);
|
||||
|
||||
TitleTextBlock.FontSize = Math.Clamp(13 * scale, 10, 22);
|
||||
StatusTextBlock.FontSize = Math.Clamp(11 * scale, 9, 18);
|
||||
SessionListPanel.Spacing = _isUltraCompactMode
|
||||
? Math.Clamp(4 * scale, 2, 5)
|
||||
: Math.Clamp(6 * scale, 3, 8);
|
||||
|
||||
DialogOverlayBorder.Padding = new Thickness(
|
||||
Math.Clamp(12 * scale, 8, 20),
|
||||
Math.Clamp(10 * scale, 8, 18));
|
||||
DialogCardBorder.CornerRadius = new CornerRadius(Math.Clamp(12 * scale, 10, 18));
|
||||
DialogCardBorder.Padding = new Thickness(
|
||||
Math.Clamp(12 * scale, 9, 20),
|
||||
Math.Clamp(11 * scale, 8, 18));
|
||||
DialogTitleTextBlock.FontSize = Math.Clamp(14 * scale, 11, 20);
|
||||
DialogMessageTextBlock.FontSize = Math.Clamp(12 * scale, 10, 17);
|
||||
DialogRenameTextBox.FontSize = Math.Clamp(11.5 * scale, 10, 16);
|
||||
DialogCancelButton.FontSize = Math.Clamp(11 * scale, 10, 16);
|
||||
DialogConfirmButton.FontSize = Math.Clamp(11 * scale, 10, 16);
|
||||
DialogCancelButton.Height = Math.Clamp(30 * scale, 26, 38);
|
||||
DialogConfirmButton.Height = Math.Clamp(30 * scale, 26, 38);
|
||||
}
|
||||
|
||||
private static StudySessionHistoryEntry? FindHistoryEntry(IReadOnlyList<StudySessionHistoryEntry> history, string? sessionId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
for (var i = 0; i < history.Count; i++)
|
||||
{
|
||||
var entry = history[i];
|
||||
if (string.Equals(entry.SessionId, sessionId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private string FormatDuration(TimeSpan duration)
|
||||
{
|
||||
if (duration < TimeSpan.Zero)
|
||||
{
|
||||
duration = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
if (duration.TotalHours >= 1)
|
||||
{
|
||||
var totalHours = (int)Math.Floor(duration.TotalHours);
|
||||
return string.Format(CultureInfo.InvariantCulture, "{0:00}:{1:00}:{2:00}", totalHours, duration.Minutes, duration.Seconds);
|
||||
}
|
||||
|
||||
return string.Format(CultureInfo.InvariantCulture, "{0:00}:{1:00}", duration.Minutes, duration.Seconds);
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
private Color ResolvePanelBackgroundColor()
|
||||
{
|
||||
if (RootBorder.Background is ISolidColorBrush solidBackground)
|
||||
{
|
||||
return solidBackground.Color;
|
||||
}
|
||||
|
||||
if (Resources.TryGetResource("AdaptiveGlassStrongBackgroundBrush", ActualThemeVariant, out var resource) &&
|
||||
resource is ISolidColorBrush solidBrush)
|
||||
{
|
||||
return solidBrush.Color;
|
||||
}
|
||||
|
||||
return Color.Parse("#FF1E293B");
|
||||
}
|
||||
|
||||
private static IReadOnlyList<Color> BuildPanelBackgroundSamples(Color panelColor)
|
||||
{
|
||||
var opaqueOnDark = ToOpaqueAgainst(panelColor, DarkSubstrate);
|
||||
var opaqueOnLight = ToOpaqueAgainst(panelColor, LightSubstrate);
|
||||
|
||||
return
|
||||
[
|
||||
opaqueOnDark,
|
||||
opaqueOnLight,
|
||||
ColorMath.Blend(opaqueOnDark, DarkSubstrate, 0.22),
|
||||
ColorMath.Blend(opaqueOnDark, Color.Parse("#FFFFFFFF"), 0.14),
|
||||
ColorMath.Blend(opaqueOnLight, Color.Parse("#FFFFFFFF"), 0.08)
|
||||
];
|
||||
}
|
||||
|
||||
private static Color ToOpaqueAgainst(Color foreground, Color background)
|
||||
{
|
||||
if (foreground.A >= 0xFF)
|
||||
{
|
||||
return Color.FromArgb(0xFF, foreground.R, foreground.G, foreground.B);
|
||||
}
|
||||
|
||||
var alpha = foreground.A / 255d;
|
||||
var red = (byte)Math.Round((foreground.R * alpha) + (background.R * (1 - alpha)));
|
||||
var green = (byte)Math.Round((foreground.G * alpha) + (background.G * (1 - alpha)));
|
||||
var blue = (byte)Math.Round((foreground.B * alpha) + (background.B * (1 - alpha)));
|
||||
return Color.FromArgb(0xFF, red, green, blue);
|
||||
}
|
||||
|
||||
private static IBrush CreateAdaptiveBrush(IReadOnlyList<Color> backgroundSamples, IReadOnlyList<Color> candidates, double minContrast)
|
||||
{
|
||||
var selected = candidates[0];
|
||||
var bestRatio = double.MinValue;
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var ratio = MinContrastRatio(candidate, backgroundSamples);
|
||||
if (ratio >= minContrast)
|
||||
{
|
||||
selected = candidate;
|
||||
bestRatio = ratio;
|
||||
break;
|
||||
}
|
||||
|
||||
if (ratio > bestRatio)
|
||||
{
|
||||
bestRatio = ratio;
|
||||
selected = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return new SolidColorBrush(Color.FromArgb(0xFF, selected.R, selected.G, selected.B));
|
||||
}
|
||||
|
||||
private static double MinContrastRatio(Color foreground, IReadOnlyList<Color> backgrounds)
|
||||
{
|
||||
var min = double.MaxValue;
|
||||
for (var i = 0; i < backgrounds.Count; i++)
|
||||
{
|
||||
var ratio = ColorMath.ContrastRatio(foreground, backgrounds[i]);
|
||||
if (ratio < min)
|
||||
{
|
||||
min = ratio;
|
||||
}
|
||||
}
|
||||
|
||||
return min;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1479,14 +1479,15 @@ public partial class MainWindow
|
||||
private void ApplyDesktopEditStateToHost(Border host, bool isEditMode)
|
||||
{
|
||||
host.IsHitTestVisible = true;
|
||||
var keepContentInteractive = ShouldKeepContentInteractiveInEditMode(host);
|
||||
|
||||
if (TryGetContentHost(host) is Border contentHost)
|
||||
{
|
||||
// In edit mode, prefer drag interactions over component interactions.
|
||||
contentHost.IsHitTestVisible = !isEditMode;
|
||||
// In edit mode, keep selected interactive widgets usable; drag/resize still uses host border/handles.
|
||||
contentHost.IsHitTestVisible = !isEditMode || keepContentInteractive;
|
||||
if (contentHost.Child is Control componentControl)
|
||||
{
|
||||
componentControl.IsHitTestVisible = !isEditMode;
|
||||
componentControl.IsHitTestVisible = !isEditMode || keepContentInteractive;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1494,6 +1495,27 @@ public partial class MainWindow
|
||||
ApplySelectionStateToHost(host, isSelected);
|
||||
}
|
||||
|
||||
private bool ShouldKeepContentInteractiveInEditMode(Border host)
|
||||
{
|
||||
if (!_isComponentLibraryOpen ||
|
||||
host.Tag is not string placementId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var placement = _desktopComponentPlacements.FirstOrDefault(p =>
|
||||
string.Equals(p.PlacementId, placementId, StringComparison.OrdinalIgnoreCase));
|
||||
if (placement is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(
|
||||
placement.ComponentId,
|
||||
BuiltInComponentIds.DesktopStudySessionHistory,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void OnDesktopComponentHostPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!_isComponentLibraryOpen || _isDesktopComponentDragActive || _isDesktopComponentResizeActive)
|
||||
|
||||
@@ -33,8 +33,12 @@ public partial class MainWindow
|
||||
private double _desktopSurfacePageWidth;
|
||||
private TranslateTransform? _desktopPagesHostTransform;
|
||||
private bool _isDesktopSwipeActive;
|
||||
private bool _isDesktopSwipeDirectionLocked;
|
||||
private Point _desktopSwipeStartPoint;
|
||||
private Point _desktopSwipeCurrentPoint;
|
||||
private Point _desktopSwipeLastPoint;
|
||||
private long _desktopSwipeLastTimestamp;
|
||||
private double _desktopSwipeVelocityX;
|
||||
private double _desktopSwipeBaseOffset;
|
||||
|
||||
private int LauncherSurfaceIndex => Math.Max(MinDesktopPageCount, _desktopPageCount);
|
||||
@@ -48,6 +52,15 @@ public partial class MainWindow
|
||||
_currentDesktopSurfaceIndex = Math.Clamp(snapshot.CurrentDesktopSurfaceIndex, 0, LauncherSurfaceIndex);
|
||||
}
|
||||
|
||||
private void InitializeDesktopSurfaceSwipeHandlers()
|
||||
{
|
||||
// Capture swipe intent before child controls consume pointer events.
|
||||
AddHandler(PointerPressedEvent, OnDesktopPagesPointerPressed, RoutingStrategies.Tunnel, handledEventsToo: true);
|
||||
AddHandler(PointerMovedEvent, OnDesktopPagesPointerMoved, RoutingStrategies.Tunnel, handledEventsToo: true);
|
||||
AddHandler(PointerReleasedEvent, OnDesktopPagesPointerReleased, RoutingStrategies.Tunnel, handledEventsToo: true);
|
||||
AddHandler(PointerCaptureLostEvent, OnDesktopPagesPointerCaptureLost, RoutingStrategies.Tunnel, handledEventsToo: true);
|
||||
}
|
||||
|
||||
private async void LoadLauncherEntriesAsync()
|
||||
{
|
||||
try
|
||||
@@ -292,7 +305,12 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
var target = Math.Clamp(_currentDesktopSurfaceIndex + delta, 0, LauncherSurfaceIndex);
|
||||
MoveSurfaceTo(_currentDesktopSurfaceIndex + delta);
|
||||
}
|
||||
|
||||
private void MoveSurfaceTo(int targetIndex)
|
||||
{
|
||||
var target = Math.Clamp(targetIndex, 0, LauncherSurfaceIndex);
|
||||
if (target == _currentDesktopSurfaceIndex)
|
||||
{
|
||||
ApplyDesktopSurfaceOffset();
|
||||
@@ -306,12 +324,16 @@ public partial class MainWindow
|
||||
|
||||
private bool CanSwipeDesktopSurface()
|
||||
{
|
||||
return !_isSettingsOpen && !_isDesktopComponentDragActive && _desktopSurfacePageWidth > 1;
|
||||
return !_isSettingsOpen &&
|
||||
!_isComponentLibraryOpen &&
|
||||
!_isDesktopComponentDragActive &&
|
||||
!_isDesktopComponentResizeActive &&
|
||||
_desktopSurfacePageWidth > 1;
|
||||
}
|
||||
|
||||
private void OnDesktopPagesPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (DesktopPagesViewport is null)
|
||||
if (!TryGetPointerPositionInDesktopViewport(e, out var pointerInViewport))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -336,16 +358,24 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsDesktopSwipeBlockedPointerSource(e.Source))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.GetCurrentPoint(DesktopPagesViewport).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isDesktopSwipeActive = true;
|
||||
_desktopSwipeStartPoint = e.GetPosition(DesktopPagesViewport);
|
||||
_isDesktopSwipeDirectionLocked = false;
|
||||
_desktopSwipeStartPoint = pointerInViewport;
|
||||
_desktopSwipeCurrentPoint = _desktopSwipeStartPoint;
|
||||
_desktopSwipeLastPoint = _desktopSwipeStartPoint;
|
||||
_desktopSwipeVelocityX = 0;
|
||||
_desktopSwipeLastTimestamp = Stopwatch.GetTimestamp();
|
||||
_desktopSwipeBaseOffset = -_currentDesktopSurfaceIndex * _desktopSurfacePageWidth;
|
||||
e.Pointer.Capture(DesktopPagesViewport);
|
||||
}
|
||||
|
||||
private static bool IsInteractivePointerSource(object? source)
|
||||
@@ -376,24 +406,172 @@ public partial class MainWindow
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsDesktopSwipeBlockedPointerSource(object? source)
|
||||
{
|
||||
if (source is not Visual visual)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var pendingNodes = new Stack<object>();
|
||||
var visitedNodes = new HashSet<object>(ReferenceEqualityComparer.Instance);
|
||||
pendingNodes.Push(visual);
|
||||
|
||||
while (pendingNodes.Count > 0)
|
||||
{
|
||||
var node = pendingNodes.Pop();
|
||||
if (!visitedNodes.Add(node))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsDesktopSwipeBlockingNode(node))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node is StyledElement styledElement &&
|
||||
styledElement.TemplatedParent is { } templatedParent)
|
||||
{
|
||||
pendingNodes.Push(templatedParent);
|
||||
}
|
||||
|
||||
if (node is Visual currentVisual &&
|
||||
currentVisual.GetVisualParent() is { } parentVisual)
|
||||
{
|
||||
pendingNodes.Push(parentVisual);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsDesktopSwipeBlockingNode(object node)
|
||||
{
|
||||
if (node is Button or TextBox or ComboBox or Slider or ToggleSwitch or ListBoxItem or ScrollViewer)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node is Control control &&
|
||||
(control.Classes.Contains("study-history-action-button") ||
|
||||
control.Classes.Contains("desktop-component") ||
|
||||
control.Classes.Contains("desktop-component-host")))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var typeName = node.GetType().Name;
|
||||
return typeName.Contains("Button", StringComparison.OrdinalIgnoreCase) ||
|
||||
typeName.Contains("WebView", StringComparison.OrdinalIgnoreCase) ||
|
||||
typeName.Contains("ScrollBar", StringComparison.OrdinalIgnoreCase) ||
|
||||
typeName.Contains("NumericUpDown", StringComparison.OrdinalIgnoreCase) ||
|
||||
typeName.Contains("TextPresenter", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private bool TryGetPointerPositionInDesktopViewport(PointerEventArgs e, out Point point)
|
||||
{
|
||||
point = default;
|
||||
if (DesktopPagesViewport is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
point = e.GetPosition(DesktopPagesViewport);
|
||||
if (_isDesktopSwipeActive && _isDesktopSwipeDirectionLocked)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var bounds = DesktopPagesViewport.Bounds;
|
||||
return bounds.Width > 1 &&
|
||||
bounds.Height > 1 &&
|
||||
point.X >= 0 &&
|
||||
point.Y >= 0 &&
|
||||
point.X <= bounds.Width &&
|
||||
point.Y <= bounds.Height;
|
||||
}
|
||||
|
||||
private void UpdateDesktopSwipeVelocity(Point pointer)
|
||||
{
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
if (_desktopSwipeLastTimestamp > 0)
|
||||
{
|
||||
var elapsedSeconds = (now - _desktopSwipeLastTimestamp) / (double)Stopwatch.Frequency;
|
||||
if (elapsedSeconds > 0.0001)
|
||||
{
|
||||
var instantVelocity = (pointer.X - _desktopSwipeLastPoint.X) / elapsedSeconds;
|
||||
_desktopSwipeVelocityX = _desktopSwipeVelocityX * 0.7 + instantVelocity * 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
_desktopSwipeLastPoint = pointer;
|
||||
_desktopSwipeLastTimestamp = now;
|
||||
}
|
||||
|
||||
private void OnDesktopPagesPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (!_isDesktopSwipeActive || DesktopPagesViewport is null || _desktopPagesHostTransform is null)
|
||||
if (!_isDesktopSwipeActive || !TryGetPointerPositionInDesktopViewport(e, out var pointerInViewport))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_desktopSwipeCurrentPoint = e.GetPosition(DesktopPagesViewport);
|
||||
if (_desktopPagesHostTransform is null || DesktopPagesViewport is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_desktopSwipeCurrentPoint = pointerInViewport;
|
||||
UpdateDesktopSwipeVelocity(pointerInViewport);
|
||||
var deltaX = _desktopSwipeCurrentPoint.X - _desktopSwipeStartPoint.X;
|
||||
var deltaY = _desktopSwipeCurrentPoint.Y - _desktopSwipeStartPoint.Y;
|
||||
|
||||
if (!_isDesktopSwipeDirectionLocked)
|
||||
{
|
||||
const double activationThreshold = 14;
|
||||
const double horizontalBias = 1.15;
|
||||
var absDeltaX = Math.Abs(deltaX);
|
||||
var absDeltaY = Math.Abs(deltaY);
|
||||
|
||||
if (absDeltaY >= activationThreshold && absDeltaY > absDeltaX * horizontalBias)
|
||||
{
|
||||
CancelDesktopSwipeInteraction(e.Pointer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (absDeltaX < activationThreshold || absDeltaX <= absDeltaY * horizontalBias)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isDesktopSwipeDirectionLocked = true;
|
||||
if (e.Pointer.Captured != DesktopPagesViewport)
|
||||
{
|
||||
e.Pointer.Capture(DesktopPagesViewport);
|
||||
}
|
||||
}
|
||||
|
||||
var minOffset = -LauncherSurfaceIndex * _desktopSurfacePageWidth;
|
||||
var tentative = _desktopSwipeBaseOffset + deltaX;
|
||||
_desktopPagesHostTransform.X = Math.Clamp(tentative, minOffset, 0);
|
||||
if (tentative > 0)
|
||||
{
|
||||
tentative *= 0.24;
|
||||
}
|
||||
else if (tentative < minOffset)
|
||||
{
|
||||
tentative = minOffset + (tentative - minOffset) * 0.24;
|
||||
}
|
||||
|
||||
_desktopPagesHostTransform.X = tentative;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnDesktopPagesPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
EndDesktopSwipeInteraction(e.Pointer);
|
||||
if (EndDesktopSwipeInteraction(e.Pointer))
|
||||
{
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDesktopPagesPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
|
||||
@@ -401,29 +579,83 @@ public partial class MainWindow
|
||||
EndDesktopSwipeInteraction(e.Pointer);
|
||||
}
|
||||
|
||||
private void EndDesktopSwipeInteraction(IPointer? pointer)
|
||||
private void CancelDesktopSwipeInteraction(IPointer? pointer)
|
||||
{
|
||||
if (!_isDesktopSwipeActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isDesktopSwipeActive = false;
|
||||
var wasDirectionLocked = _isDesktopSwipeDirectionLocked;
|
||||
if (pointer?.Captured == DesktopPagesViewport)
|
||||
{
|
||||
pointer.Capture(null);
|
||||
}
|
||||
|
||||
_isDesktopSwipeActive = false;
|
||||
_isDesktopSwipeDirectionLocked = false;
|
||||
_desktopSwipeVelocityX = 0;
|
||||
_desktopSwipeLastTimestamp = 0;
|
||||
if (wasDirectionLocked)
|
||||
{
|
||||
ApplyDesktopSurfaceOffset();
|
||||
}
|
||||
}
|
||||
|
||||
private bool EndDesktopSwipeInteraction(IPointer? pointer)
|
||||
{
|
||||
if (!_isDesktopSwipeActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var wasDirectionLocked = _isDesktopSwipeDirectionLocked;
|
||||
_isDesktopSwipeActive = false;
|
||||
_isDesktopSwipeDirectionLocked = false;
|
||||
if (pointer?.Captured == DesktopPagesViewport)
|
||||
{
|
||||
pointer.Capture(null);
|
||||
}
|
||||
|
||||
_desktopSwipeLastTimestamp = 0;
|
||||
if (!wasDirectionLocked)
|
||||
{
|
||||
_desktopSwipeVelocityX = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
var deltaX = _desktopSwipeCurrentPoint.X - _desktopSwipeStartPoint.X;
|
||||
var deltaY = _desktopSwipeCurrentPoint.Y - _desktopSwipeStartPoint.Y;
|
||||
var threshold = Math.Max(56, _desktopSurfacePageWidth * 0.16);
|
||||
if (Math.Abs(deltaX) >= threshold && Math.Abs(deltaX) > Math.Abs(deltaY))
|
||||
var absDeltaX = Math.Abs(deltaX);
|
||||
var absDeltaY = Math.Abs(deltaY);
|
||||
var distanceThreshold = Math.Max(48, _desktopSurfacePageWidth * 0.14);
|
||||
var velocityThreshold = Math.Max(860, _desktopSurfacePageWidth * 1.08);
|
||||
var predictedDeltaX = deltaX + _desktopSwipeVelocityX * 0.18;
|
||||
var predictedOffset = _desktopSwipeBaseOffset + predictedDeltaX;
|
||||
var projectedTargetIndex = (int)Math.Round(-predictedOffset / _desktopSurfacePageWidth);
|
||||
projectedTargetIndex = Math.Clamp(projectedTargetIndex, 0, LauncherSurfaceIndex);
|
||||
|
||||
var hasDistanceIntent = absDeltaX >= distanceThreshold && absDeltaX > absDeltaY * 1.05;
|
||||
var hasVelocityIntent = Math.Abs(_desktopSwipeVelocityX) >= velocityThreshold;
|
||||
|
||||
if (projectedTargetIndex == _currentDesktopSurfaceIndex && (hasDistanceIntent || hasVelocityIntent))
|
||||
{
|
||||
MoveSurfaceBy(deltaX < 0 ? 1 : -1);
|
||||
return;
|
||||
projectedTargetIndex = Math.Clamp(
|
||||
_currentDesktopSurfaceIndex + (deltaX < 0 ? 1 : -1),
|
||||
0,
|
||||
LauncherSurfaceIndex);
|
||||
}
|
||||
|
||||
_desktopSwipeVelocityX = 0;
|
||||
|
||||
if (projectedTargetIndex != _currentDesktopSurfaceIndex)
|
||||
{
|
||||
MoveSurfaceTo(projectedTargetIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
ApplyDesktopSurfaceOffset();
|
||||
return hasDistanceIntent || hasVelocityIntent;
|
||||
}
|
||||
|
||||
private void OnDesktopPagesPointerWheelChanged(object? sender, PointerWheelEventArgs e)
|
||||
|
||||
@@ -100,10 +100,6 @@
|
||||
Background="Transparent"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
PointerPressed="OnDesktopPagesPointerPressed"
|
||||
PointerMoved="OnDesktopPagesPointerMoved"
|
||||
PointerReleased="OnDesktopPagesPointerReleased"
|
||||
PointerCaptureLost="OnDesktopPagesPointerCaptureLost"
|
||||
PointerWheelChanged="OnDesktopPagesPointerWheelChanged">
|
||||
<Grid>
|
||||
<Grid x:Name="DesktopPagesHost"
|
||||
|
||||
@@ -164,6 +164,7 @@ public partial class MainWindow : Window
|
||||
_componentRuntimeRegistry = DesktopComponentRuntimeRegistry.CreateDefault(_componentRegistry);
|
||||
_fluentAvaloniaTheme = Application.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault();
|
||||
PropertyChanged += OnWindowPropertyChanged;
|
||||
InitializeDesktopSurfaceSwipeHandlers();
|
||||
InitializeDesktopComponentDragHandlers();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user