DragAndDrop core

This commit is contained in:
FrigaT
2026-01-18 16:33:35 +03:00
parent 9ea82af329
commit 79bdd8bc62
229 changed files with 21214 additions and 2494 deletions

View File

@@ -0,0 +1,8 @@
namespace Lattice.Core.Docking.Abstractions;
public interface IDockCommand : System.Windows.Input.ICommand
{
string Name { get; }
string Icon { get; }
string GestureText { get; }
}

View File

@@ -0,0 +1,24 @@
using Lattice.Core.Docking.Models;
namespace Lattice.Core.Docking.Abstractions;
/// <summary>
/// Интерфейс для элементов (листьев дерева), которые физически содержат внутри себя коллекцию вкладок.
/// </summary>
public interface IDockContainer : IDockElement
{
/// <summary> Список вкладок, находящихся в данном контейнере. </summary>
IList<IDockContent> Children { get; }
/// <summary> Ссылка на текущую выбранную и отображаемую вкладку. </summary>
IDockContent? ActiveContent { get; set; }
/// <summary> Добавляет контент в контейнер и делает его активным. </summary>
void AddContent(IDockContent content);
/// <summary> Удаляет контент. Если Children становится пустым, контейнер может быть удален из дерева макета. </summary>
void RemoveContent(IDockContent content);
/// <summary> Положение вкладок в интерфейсе. </summary>
TabPlacement TabPlacement { get; set; }
}

View File

@@ -0,0 +1,25 @@
namespace Lattice.Core.Docking.Abstractions;
/// <summary>
/// Описывает объект содержимого (вкладку), который может быть размещен внутри IDockContainer.
/// </summary>
public interface IDockContent
{
/// <summary> Уникальный идентификатор контента (например, путь к файлу или ID инструмента). </summary>
string Id { get; }
/// <summary> Заголовок, отображаемый пользователю в интерфейсе (на вкладке). </summary>
string Title { get; }
/// <summary>
/// Сам визуальный элемент (например, Microsoft.UI.Xaml.UIElement).
/// Lattice просто отображает этот объект в теле вкладки.
/// </summary>
object View { get; set; }
/// <summary> Флаг, определяющий доступность кнопки закрытия для пользователя. </summary>
bool CanClose { get; }
/// <summary> Вызывается системой при попытке закрытия контента. Возвращает true, если закрытие разрешено. </summary>
bool OnClosing();
}

View File

@@ -0,0 +1,25 @@
namespace Lattice.Core.Docking.Abstractions;
/// <summary>
/// Базовый интерфейс для любого элемента, который может быть частью дерева компоновки Lattice.
/// </summary>
public interface IDockElement
{
/// <summary> Уникальный идентификатор элемента. </summary>
string Id { get; }
/// <summary> Родительский элемент в иерархии. Если null — элемент является корневым. </summary>
IDockElement? Parent { get; set; }
/// <summary> Желаемая ширина элемента в относительных или абсолютных единицах. </summary>
double Width { get; set; }
/// <summary> Желаемая высота элемента в относительных или абсолютных единицах. </summary>
double Height { get; set; }
/// <summary> Минимально допустимая ширина, при которой элемент сохраняет функциональность. </summary>
double MinWidth { get; }
/// <summary> Минимально допустимая высота, при которой элемент сохраняет функциональность. </summary>
double MinHeight { get; }
}

View File

@@ -0,0 +1,19 @@
using Lattice.Core.DragDrop.Abstractions;
namespace Lattice.Core.Docking.Abstractions;
/// <summary>
/// Расширяет интерфейс элемента док-системы для поддержки операций перетаскивания.
/// </summary>
public interface IDockElementDragSource : IDockElement, IDragSource
{
/// <summary>
/// Получает или устанавливает признак того, что элемент можно перетаскивать.
/// </summary>
bool CanDrag { get; set; }
/// <summary>
/// Получает тип данных для перетаскивания этого элемента.
/// </summary>
string DragDataType { get; }
}

View File

@@ -0,0 +1,19 @@
using Lattice.Core.DragDrop.Abstractions;
namespace Lattice.Core.Docking.Abstractions;
/// <summary>
/// Расширяет интерфейс элемента док-системы для возможности быть целью сброса.
/// </summary>
public interface IDockElementDropTarget : IDockElement, IDropTarget
{
/// <summary>
/// Получает или устанавливает признак того, что элемент может принимать сброс.
/// </summary>
bool CanDrop { get; set; }
/// <summary>
/// Получает типы данных, которые может принимать элемент.
/// </summary>
IEnumerable<string> AcceptableDropTypes { get; }
}

View File

@@ -0,0 +1,76 @@
using Lattice.Core.Docking.Models;
using Lattice.Core.Geometry;
namespace Lattice.Core.Docking.Abstractions;
/// <summary>
/// Предоставляет абстракцию для операции перетаскивания в док-системе.
/// Эта абстракция позволяет отделить логику перетаскивания от конкретной UI-платформы.
/// </summary>
public interface IDragService
{
/// <summary>
/// Начинает операцию перетаскивания указанного элемента.
/// </summary>
/// <param name="element">Элемент для перетаскивания.</param>
/// <param name="visualFeedback">Визуальная обратная связь (зависит от платформы).</param>
void StartDrag(IDockElement element, object? visualFeedback = null);
/// <summary>
/// Обновляет позицию перетаскивания.
/// </summary>
/// <param name="x">Координата X.</param>
/// <param name="y">Координата Y.</param>
void UpdateDrag(double x, double y);
/// <summary>
/// Завершает операцию перетаскивания.
/// </summary>
/// <param name="x">Координата X завершения.</param>
/// <param name="y">Координата Y завершения.</param>
void EndDrag(double x, double y);
/// <summary>
/// Отменяет операцию перетаскивания.
/// </summary>
void CancelDrag();
}
/// <summary>
/// Представляет область для сброса при операции перетаскивания.
/// </summary>
public class DropArea
{
/// <summary>
/// Целевой элемент для сброса.
/// </summary>
public IDockElement Target { get; set; }
/// <summary>
/// Позиция сброса относительно цели.
/// </summary>
public DockPosition Position { get; set; }
/// <summary>
/// Границы области в экранных координатах.
/// </summary>
public Rect Bounds { get; set; }
/// <summary>
/// Видимость области (для анимации).
/// </summary>
public double Visibility { get; set; } = 0.0;
/// <summary>
/// Инициализирует новый экземпляр области сброса.
/// </summary>
/// <param name="target">Целевой элемент.</param>
/// <param name="position">Позиция сброса.</param>
/// <param name="bounds">Границы области.</param>
public DropArea(IDockElement target, DockPosition position, Rect bounds)
{
Target = target;
Position = position;
Bounds = bounds;
}
}

View File

@@ -0,0 +1,89 @@
using Lattice.Core.Docking.Abstractions;
using Lattice.Core.Docking.Models;
namespace Lattice.Core.Docking.Engine;
/// <summary>
/// Статический движок для манипуляции иерархией дерева компоновки.
/// Содержит чистые алгоритмы трансформации графа.
/// </summary>
public static class DockOperations
{
/// <summary>
/// Извлекает элемент из дерева. Если родительская группа остается с одним ребенком,
/// она удаляется, а ребенок занимает её место.
/// </summary>
/// <param name="element">Элемент для удаления.</param>
/// <param name="root">Текущий корень дерева.</param>
/// <returns>Новый корень дерева после оптимизации.</returns>
public static IDockElement? Remove(IDockElement element, IDockElement root)
{
if (element == root) return null;
var parent = element.Parent as DockGroup;
if (parent == null) return root;
// Определяем "выжившего" соседа
var sibling = (parent.First == element) ? parent.Second : parent.First;
var grandParent = parent.Parent as DockGroup;
if (grandParent != null)
{
// Переподключаем соседа напрямую к дедушке
if (grandParent.First == parent) grandParent.First = sibling;
else grandParent.Second = sibling;
sibling.Parent = grandParent;
return root;
}
// Если дедушки нет, сосед становится новым корнем
sibling.Parent = null;
return sibling;
}
/// <summary>
/// Вставляет элемент в дерево, создавая новую группу разделения или объединяя контент.
/// </summary>
public static IDockElement Insert(IDockElement target, IDockElement source, DockPosition pos, IDockElement root)
{
// Случай 1: Объединение вкладок в центре
if (pos == DockPosition.Center)
{
if (target is IDockContainer targetContainer && source is IDockContainer sourceContainer)
{
var items = new List<IDockContent>(sourceContainer.Children);
foreach (var item in items)
{
sourceContainer.RemoveContent(item);
targetContainer.AddContent(item);
}
}
return root;
}
// Случай 2: Разделение (Split)
var direction = (pos == DockPosition.Left || pos == DockPosition.Right)
? SplitDirection.Horizontal : SplitDirection.Vertical;
bool sourceIsFirst = (pos == DockPosition.Left || pos == DockPosition.Top);
var oldParent = target.Parent;
// Создаем новую группу. Источник и цель делят пространство 50/50
var newGroup = sourceIsFirst
? new DockGroup(source, target, direction) { SplitRatio = 0.5 }
: new DockGroup(target, source, direction) { SplitRatio = 0.5 };
if (oldParent is DockGroup gp)
{
if (gp.First == target) gp.First = newGroup;
else gp.Second = newGroup;
newGroup.Parent = gp;
return root;
}
newGroup.Parent = null;
return newGroup; // Новая группа стала корнем
}
}

View File

@@ -0,0 +1,369 @@
using Lattice.Core.Docking.Abstractions;
using Lattice.Core.Docking.Models;
using System.Collections.ObjectModel;
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Lattice.Serialization.Docking")]
namespace Lattice.Core.Docking.Engine;
/// <summary>
/// Расширенный менеджер макета, поддерживающий автоскрываемые панели, группы документов
/// и расширенные операции управления макетом.
/// </summary>
/// <remarks>
/// Этот класс является центральным координатором всей док-системы, управляя деревом компоновки,
/// плавающими окнами, автоскрываемыми панелями и предоставляя API для манипуляции макетом.
/// </remarks>
public class LayoutManager
{
private readonly ObservableCollection<AutoHidePanel> _autoHidePanels = new();
/// <summary>
/// Корневой элемент главного окна IDE.
/// </summary>
public IDockElement? Root { get; internal set; }
/// <summary>
/// Список активных плавающих окон.
/// </summary>
public List<DockWindow> FloatingWindows { get; } = new();
/// <summary>
/// Коллекция автоскрываемых панелей.
/// </summary>
public ReadOnlyObservableCollection<AutoHidePanel> AutoHidePanels { get; }
/// <summary>
/// Реестр типов контента (опционально).
/// </summary>
public Services.ContentRegistry? ContentRegistry { get; set; }
/// <summary>
/// Уведомляет UI, что структура дерева изменилась.
/// </summary>
public event Action? LayoutUpdated;
/// <summary>
/// Уведомляет об изменении в коллекции автоскрываемых панелей.
/// </summary>
public event EventHandler? AutoHidePanelsChanged;
/// <summary>
/// Событие, возникающее при операции перетаскивания элемента.
/// </summary>
public event EventHandler<DragDropEventArgs>? DragDropOperation;
/// <summary>
/// Инициализирует новый экземпляр менеджера макета.
/// </summary>
public LayoutManager()
{
AutoHidePanels = new ReadOnlyObservableCollection<AutoHidePanel>(_autoHidePanels);
}
/// <summary>
/// Добавляет автоскрываемую панель.
/// </summary>
/// <param name="content">Содержимое панели.</param>
/// <param name="side">Сторона для прикрепления.</param>
/// <returns>Созданная автоскрываемая панель.</returns>
public AutoHidePanel AddAutoHidePanel(IDockContent content, DockSide side)
{
var panel = new AutoHidePanel(content, side);
_autoHidePanels.Add(panel);
AutoHidePanelsChanged?.Invoke(this, EventArgs.Empty);
return panel;
}
/// <summary>
/// Удаляет автоскрываемую панель.
/// </summary>
/// <param name="panel">Панель для удаления.</param>
public void RemoveAutoHidePanel(AutoHidePanel panel)
{
if (_autoHidePanels.Remove(panel))
{
AutoHidePanelsChanged?.Invoke(this, EventArgs.Empty);
}
}
/// <summary>
/// Создает документ из зарегистрированного типа контента.
/// </summary>
/// <param name="contentTypeId">Идентификатор типа контента.</param>
/// <param name="id">Уникальный идентификатор документа.</param>
/// <returns>Созданный контент или null, если ContentRegistry не установлен.</returns>
public IDockContent? CreateDocument(string contentTypeId, string id)
{
if (ContentRegistry == null || !ContentRegistry.IsRegistered(contentTypeId))
return null;
return ContentRegistry.CreateContent(contentTypeId, id);
}
/// <summary>
/// Основной метод перемещения элементов в макете.
/// </summary>
/// <param name="source">Что перетаскиваем.</param>
/// <param name="target">Куда приземляем.</param>
/// <param name="position">Позиция относительно цели.</param>
/// <param name="asDocument">
/// Если true, контент будет добавлен как документ в центральную область.
/// </param>
public void Move(IDockElement source, IDockElement? target, DockPosition position, bool asDocument = false)
{
if (source == target) return;
// 1. Удаляем источник из текущего местоположения
bool sourceRemoved = false;
if (Root != null && IsDescendantOf(source, Root))
{
Root = DockOperations.Remove(source, Root);
sourceRemoved = true;
}
else
{
sourceRemoved = RemoveFromFloatingWindows(source);
}
if (!sourceRemoved)
{
// Проверяем автоскрываемые панели
var autoHidePanel = _autoHidePanels.FirstOrDefault(p => p.Content == source);
if (autoHidePanel != null)
{
_autoHidePanels.Remove(autoHidePanel);
sourceRemoved = true;
AutoHidePanelsChanged?.Invoke(this, EventArgs.Empty);
}
}
if (!sourceRemoved) return;
// 2. Вставляем в цель
if (target == null)
{
FloatingWindows.Add(new DockWindow { Root = source as IDockElement });
}
else
{
if (IsDescendantOf(target, Root))
{
Root = DockOperations.Insert(target, source, position, Root!);
}
else
{
InsertIntoFloatingWindow(target, source, position);
}
}
LayoutUpdated?.Invoke();
}
private bool RemoveFromFloatingWindows(IDockElement element)
{
foreach (var win in FloatingWindows.ToArray())
{
if (win.Root != null && IsDescendantOf(element, win.Root))
{
win.Root = DockOperations.Remove(element, win.Root);
if (win.Root == null)
FloatingWindows.Remove(win);
return true;
}
}
return false;
}
private void InsertIntoFloatingWindow(IDockElement target, IDockElement source, DockPosition position)
{
foreach (var win in FloatingWindows)
{
if (win.Root != null && IsDescendantOf(target, win.Root))
{
win.Root = DockOperations.Insert(target, source, position, win.Root);
return;
}
}
}
private bool IsDescendantOf(IDockElement element, IDockElement ancestor)
{
if (element == ancestor) return true;
if (ancestor is DockGroup group)
return IsDescendantOf(element, group.First) || IsDescendantOf(element, group.Second);
return false;
}
/// <summary> Поиск элемента по ID во всех окнах. </summary>
public IDockElement? FindById(string id)
{
var found = FindRecursive(Root, id);
if (found != null) return found;
foreach (var win in FloatingWindows)
{
found = FindRecursive(win.Root, id);
if (found != null) return found;
}
return null;
}
private IDockElement? FindRecursive(IDockElement? node, string id)
{
if (node == null || node.Id == id) return node;
if (node is DockGroup g) return FindRecursive(g.First, id) ?? FindRecursive(g.Second, id);
return null;
}
/// <summary>
/// Сбрасывает макет к состоянию по умолчанию.
/// </summary>
public void Reset()
{
Root = null;
FloatingWindows.Clear();
_autoHidePanels.Clear();
LayoutUpdated?.Invoke();
AutoHidePanelsChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Обрабатывает операцию перетаскивания между элементами.
/// </summary>
/// <param name="source">Источник перетаскивания.</param>
/// <param name="target">Цель сброса.</param>
/// <param name="position">Позиция сброса относительно цели.</param>
/// <param name="data">Данные перетаскивания.</param>
/// <returns>true, если операция успешно выполнена; иначе false.</returns>
public bool HandleDragDrop(IDockElement source, IDockElement target,
DockPosition position, object data)
{
try
{
if (source == target)
return false;
// Определяем тип операции на основе данных
if (data is ContentDragData contentData)
{
return HandleContentDragDrop(contentData, target, position);
}
else if (data is DockElementDragData elementData)
{
return HandleElementDragDrop(elementData, target, position);
}
return false;
}
catch (Exception ex)
{
DragDropOperation?.Invoke(this, new DragDropEventArgs(
source, target, position, false, ex.Message));
return false;
}
}
private bool HandleContentDragDrop(ContentDragData data, IDockElement target, DockPosition position)
{
// Находим исходный контейнер с контентом
var sourceContainer = FindElementById(data.ElementId) as IDockContainer;
if (sourceContainer == null)
return false;
// Находим контент
var content = sourceContainer.Children.FirstOrDefault(c => c.Id == data.ContentId);
if (content == null)
return false;
if (target is IDockContainer targetContainer && position == DockPosition.Center)
{
// Объединение вкладок
sourceContainer.RemoveContent(content);
targetContainer.AddContent(content);
DragDropOperation?.Invoke(this, new DragDropEventArgs(
sourceContainer as IDockElement ?? sourceContainer as IDockElement,
target, position, true, "Content merged"));
return true;
}
return false;
}
private bool HandleElementDragDrop(DockElementDragData data, IDockElement target, DockPosition position)
{
// Находим перетаскиваемый элемент
var sourceElement = FindElementById(data.ElementId);
if (sourceElement == null)
return false;
// Выполняем перемещение
Move(sourceElement, target, position);
DragDropOperation?.Invoke(this, new DragDropEventArgs(
sourceElement, target, position, true, "Element moved"));
return true;
}
/// <summary>
/// Находит элемент по идентификатору.
/// </summary>
public IDockElement? FindElementById(string id)
{
return FindElementByIdRecursive(Root, id) ??
FloatingWindows.Select(w => FindElementByIdRecursive(w.Root, id))
.FirstOrDefault(result => result != null);
}
private IDockElement? FindElementByIdRecursive(IDockElement? element, string id)
{
if (element == null) return null;
if (element.Id == id) return element;
if (element is DockGroup group)
{
return FindElementByIdRecursive(group.First, id) ??
FindElementByIdRecursive(group.Second, id);
}
return null;
}
}
/// <summary>
/// Аргументы события операции перетаскивания.
/// </summary>
public class DragDropEventArgs : EventArgs
{
/// <summary> Источник перетаскивания. </summary>
public IDockElement Source { get; }
/// <summary> Цель сброса. </summary>
public IDockElement Target { get; }
/// <summary> Позиция сброса. </summary>
public DockPosition Position { get; }
/// <summary> Показывает, была ли операция успешной. </summary>
public bool Success { get; }
/// <summary> Сообщение о результате операции. </summary>
public string Message { get; }
/// <summary> Время выполнения операции. </summary>
public DateTime Timestamp { get; }
public DragDropEventArgs(IDockElement source, IDockElement target,
DockPosition position, bool success, string message)
{
Source = source;
Target = target;
Position = position;
Success = success;
Message = message;
Timestamp = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Lattice.Core.Geometry\Lattice.Core.Geometry.csproj" />
<ProjectReference Include="..\Lattice.Core.DragDrop\Lattice.Core.DragDrop.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,114 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Lattice.Core.Docking.Models;
/// <summary>
/// Представляет автоскрываемую панель, которая может быть прикреплена к одной из сторон окна.
/// Автоскрываемые панели скрываются, оставляя только заголовок, и появляются при наведении курсора.
/// </summary>
/// <remarks>
/// Автоскрываемые панели являются ключевым элементом интерфейса современных IDE,
/// позволяя экономить пространство экрана при сохранении быстрого доступа к инструментам.
/// </remarks>
public class AutoHidePanel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? name = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
private bool _isVisible = false;
private double _slideOffset = 0;
/// <summary>
/// Уникальный идентификатор автоскрываемой панели.
/// </summary>
public string Id { get; } = Guid.NewGuid().ToString();
/// <summary>
/// Содержимое панели.
/// </summary>
public Abstractions.IDockContent Content { get; set; }
/// <summary>
/// Сторона окна, к которой прикреплена панель.
/// </summary>
public DockSide Side { get; set; }
/// <summary>
/// Ширина панели (для левой/правой сторон) или высота (для верхней/нижней сторон).
/// </summary>
public double Size { get; set; } = 300;
/// <summary>
/// Признак видимости панели.
/// </summary>
public bool IsVisible
{
get => _isVisible;
set
{
if (_isVisible != value)
{
_isVisible = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// Смещение для анимации выезда/заезда панели (0-1).
/// </summary>
public double SlideOffset
{
get => _slideOffset;
set
{
if (Math.Abs(_slideOffset - value) > 0.001)
{
_slideOffset = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// Заголовок панели (обычно берется из содержимого).
/// </summary>
public string Title => Content?.Title ?? "Auto-hide Panel";
/// <summary>
/// Инициализирует новый экземпляр автоскрываемой панели.
/// </summary>
/// <param name="content">Содержимое панели.</param>
/// <param name="side">Сторона окна для прикрепления.</param>
public AutoHidePanel(Abstractions.IDockContent content, DockSide side)
{
Content = content ?? throw new ArgumentNullException(nameof(content));
Side = side;
}
/// <summary>
/// Переключает видимость панели.
/// </summary>
public void Toggle()
{
IsVisible = !IsVisible;
}
/// <summary>
/// Показывает панель.
/// </summary>
public void Show()
{
IsVisible = true;
}
/// <summary>
/// Скрывает панель.
/// </summary>
public void Hide()
{
IsVisible = false;
}
}

View File

@@ -0,0 +1,444 @@
using Lattice.Core.Docking.Abstractions;
using Lattice.Core.DragDrop.Abstractions;
using Lattice.Core.DragDrop.Enums;
using Lattice.Core.DragDrop.Models;
using Lattice.Core.Geometry;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Lattice.Core.Docking.Models;
/// <summary>
/// Представляет узел дерева компоновки, который разделяет доступную область
/// между двумя дочерними элементами. Этот класс является основным структурным
/// элементом для создания сложных макетов с разделителями.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="DockGroup"/> реализует как <see cref="IDragSource"/> (для
/// возможности перетаскивания всей группы), так и <see cref="IDropTarget"/>
/// (для возможности сброса на группу), что делает его полностью интегрированным
/// в систему перетаскивания док-системы.
/// </para>
/// <para>
/// Каждая группа содержит два дочерних элемента (<see cref="First"/> и
/// <see cref="Second"/>), которые могут быть либо другими группами (для
/// создания вложенной структуры), либо листами (<see cref="DockLeaf"/>)
/// с контентом. Направление разделения определяется свойством
/// <see cref="Orientation"/>.
/// </para>
/// </remarks>
public class DockGroup : IDockElement, IDragSource, IDropTarget, INotifyPropertyChanged
{
/// <summary>
/// Событие, возникающее при изменении значения свойства.
/// </summary>
public event PropertyChangedEventHandler? PropertyChanged;
private double _splitRatio = 0.5;
private string _id;
/// <summary>
/// Получает уникальный идентификатор группы.
/// </summary>
/// <value>
/// Строковый идентификатор, уникальный в пределах дерева компоновки.
/// </value>
/// <remarks>
/// Идентификатор используется для сериализации/десериализации макета,
/// поиска элементов и отслеживания изменений в дереве.
/// </remarks>
public string Id
{
get => _id;
internal set
{
if (_id != value)
{
_id = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// Получает или задает родительский элемент в иерархии дерева компоновки.
/// </summary>
/// <value>
/// Родительский элемент или null, если эта группа является корневой.
/// </value>
/// <remarks>
/// Это свойство управляется системой компоновки при добавлении или
/// удалении элементов из дерева.
/// </remarks>
public IDockElement? Parent { get; set; }
/// <summary>
/// Получает или задает первый дочерний элемент (левую или верхнюю область).
/// </summary>
/// <value>
/// Элемент, занимающий первую часть разделенной области.
/// </value>
/// <exception cref="ArgumentNullException">
/// Выбрасывается при попытке установить значение null.
/// </exception>
/// <remarks>
/// При установке нового значения автоматически обновляется свойство
/// <see cref="Parent"/> у дочернего элемента.
/// </remarks>
public IDockElement First { get; set; }
/// <summary>
/// Получает или задает второй дочерний элемент (правую или нижнюю область).
/// </summary>
/// <value>
/// Элемент, занимающий вторую часть разделенной области.
/// </value>
/// <exception cref="ArgumentNullException">
/// Выбрасывается при попытке установить значение null.
/// </exception>
/// <remarks>
/// При установке нового значения автоматически обновляется свойство
/// <see cref="Parent"/> у дочернего элемента.
/// </remarks>
public IDockElement Second { get; set; }
/// <summary>
/// Получает или задает направление разделения данной группы.
/// </summary>
/// <value>
/// Значение перечисления <see cref="SplitDirection"/>, указывающее,
/// как разделена область: горизонтально или вертикально.
/// </value>
/// <remarks>
/// <para>
/// <see cref="SplitDirection.Horizontal"/> создает левую и правую области.
/// </para>
/// <para>
/// <see cref="SplitDirection.Vertical"/> создает верхнюю и нижнюю области.
/// </para>
/// </remarks>
public SplitDirection Orientation { get; set; }
/// <summary>
/// Получает или задает соотношение разделения между первым и вторым элементами.
/// </summary>
/// <value>
/// Значение от 0.0 до 1.0, где:
/// <list type="bullet">
/// <item>0.0 - вся область принадлежит второму элементу</item>
/// <item>0.5 - область разделена поровну</item>
/// <item>1.0 - вся область принадлежит первому элементу</item>
/// </list>
/// </value>
/// <remarks>
/// Изменение этого свойства вызывает событие <see cref="PropertyChanged"/>
/// и может привести к перерисовке пользовательского интерфейса.
/// </remarks>
public double SplitRatio
{
get => _splitRatio;
set
{
if (Math.Abs(_splitRatio - value) > double.Epsilon)
{
_splitRatio = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// Получает или задает желаемую ширину элемента.
/// </summary>
/// <value>
/// Ширина в пикселях или относительных единицах.
/// </value>
public double Width { get; set; }
/// <summary>
/// Получает или задает желаемую высоту элемента.
/// </summary>
/// <value>
/// Высота в пикселях или относительных единицах.
/// </value>
public double Height { get; set; }
/// <summary>
/// Получает минимально допустимую ширину элемента.
/// </summary>
/// <value>
/// Минимальная ширина в пикселях, при которой элемент сохраняет функциональность.
/// </value>
/// <remarks>
/// Для группы минимальная ширина вычисляется как сумма минимальных ширин
/// дочерних элементов при горизонтальной ориентации или максимум минимальных
/// ширин при вертикальной ориентации.
/// </remarks>
public double MinWidth => Orientation == SplitDirection.Horizontal
? First.MinWidth + Second.MinWidth
: Math.Max(First.MinWidth, Second.MinWidth);
/// <summary>
/// Получает минимально допустимую высоту элемента.
/// </summary>
/// <value>
/// Минимальная высота в пикселях, при которой элемент сохраняет функциональность.
/// </value>
/// <remarks>
/// Для группы минимальная высота вычисляется как сумма минимальных высот
/// дочерних элементов при вертикальной ориентации или максимум минимальных
/// высот при горизонтальной ориентации.
/// </remarks>
public double MinHeight => Orientation == SplitDirection.Vertical
? First.MinHeight + Second.MinHeight
: Math.Max(First.MinHeight, Second.MinHeight);
/// <summary>
/// Инициализирует новый экземпляр класса <see cref="DockGroup"/>.
/// </summary>
/// <param name="first">
/// Первый дочерний элемент (левая или верхняя область).
/// </param>
/// <param name="second">
/// Второй дочерний элемент (правая или нижняя область).
/// </param>
/// <param name="orientation">
/// Направление разделения между дочерними элементами.
/// </param>
/// <param name="id">
/// Уникальный идентификатор группы. Если не указан, генерируется новый GUID.
/// </param>
/// <exception cref="ArgumentNullException">
/// Выбрасывается, когда <paramref name="first"/> или <paramref name="second"/>
/// равны null.
/// </exception>
/// <remarks>
/// Конструктор автоматически устанавливает свойство <see cref="Parent"/>
/// у дочерних элементов на текущую группу и генерирует уникальный идентификатор,
/// если он не был предоставлен.
/// </remarks>
public DockGroup(IDockElement first, IDockElement second, SplitDirection orientation, string? id = null)
{
First = first ?? throw new ArgumentNullException(nameof(first));
Second = second ?? throw new ArgumentNullException(nameof(second));
Orientation = orientation;
Id = id ?? Guid.NewGuid().ToString();
First.Parent = this;
Second.Parent = this;
}
/// <summary>
/// Вызывает событие <see cref="PropertyChanged"/>.
/// </summary>
/// <param name="name">
/// Имя изменившегося свойства. Если не указано, определяется автоматически.
/// </param>
protected void OnPropertyChanged([CallerMemberName] string? name = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
#region Реализация IDragSource
/// <summary>
/// Определяет, может ли группа начать операцию перетаскивания.
/// </summary>
/// <param name="dragInfo">
/// При успешном возврате содержит информацию о перетаскивании;
/// в противном случае — null.
/// </param>
/// <returns>
/// true, если группа может начать перетаскивание; в противном случае — false.
/// </returns>
/// <remarks>
/// <para>
/// Группа может быть перетащена только если она не является корневым
/// элементом дерева (имеет родителя).
/// </para>
/// <para>
/// При успешной проверке метод заполняет <paramref name="dragInfo"/>
/// данными типа <see cref="DockElementDragData"/>.
/// </para>
/// </remarks>
public bool CanStartDrag(out DragInfo? dragInfo)
{
dragInfo = null;
// DockGroup можно перетаскивать только если он не является корневым элементом
if (Parent == null)
return false;
// Создаем данные для перетаскивания
var data = new DockElementDragData
{
ElementId = Id,
ElementType = GetType().Name,
IsGroup = true
};
dragInfo = new DragInfo(data, DragDropEffects.Move, Point.Zero, this);
return true;
}
/// <summary>
/// Начинает операцию перетаскивания для группы.
/// </summary>
/// <param name="dragInfo">
/// Информация о перетаскивании, полученная из <see cref="CanStartDrag"/>.
/// </param>
/// <returns>
/// Всегда возвращает true, так как группа не требует специальной подготовки
/// для начала перетаскивания.
/// </returns>
/// <remarks>
/// Для <see cref="DockGroup"/> этот метод не выполняет дополнительных действий,
/// так как все необходимые данные уже содержатся в <paramref name="dragInfo"/>.
/// </remarks>
public bool StartDrag(DragInfo dragInfo)
{
// DockGroup не требует дополнительной подготовки для перетаскивания
return true;
}
/// <summary>
/// Вызывается при завершении операции перетаскивания.
/// </summary>
/// <param name="dragInfo">
/// Исходная информация о перетаскивании.
/// </param>
/// <param name="effects">
/// Эффекты, которые были применены при сбросе.
/// </param>
/// <remarks>
/// <para>
/// Этот метод вызывается после того, как операция перетаскивания была
/// завершена (успешно или неуспешно).
/// </para>
/// <para>
/// Для <see cref="DockGroup"/> этот метод не выполняет действий, так как
/// все изменения в структуре дерева уже обработаны <see cref="LayoutManager"/>.
/// </para>
/// </remarks>
public void DragCompleted(DragInfo dragInfo, DragDropEffects effects)
{
// Если группа была перемещена, ничего не делаем - LayoutManager уже обработал изменение
}
/// <summary>
/// Вызывается при отмене операции перетаскивания.
/// </summary>
/// <param name="dragInfo">
/// Исходная информация о перетаскивании.
/// </param>
/// <remarks>
/// Для <see cref="DockGroup"/> отмена перетаскивания не требует специальных
/// действий, так как структура дерева не была изменена.
/// </remarks>
public void DragCancelled(DragInfo dragInfo)
{
// Отмена перетаскивания не требует действий
}
#endregion
#region Реализация IDropTarget
/// <summary>
/// Определяет, может ли группа принять сбрасываемые данные.
/// </summary>
/// <param name="dropInfo">
/// Информация о потенциальном сбросе.
/// </param>
/// <returns>
/// true, если группа может принять данные; в противном случае — false.
/// </returns>
/// <remarks>
/// <para>
/// Группа может принимать только данные типа <see cref="DockElementDragData"/>
/// для элементов док-системы (<see cref="DockGroup"/> или <see cref="DockLeaf"/>).
/// </para>
/// <para>
/// Группа не может принять сброс самой себя (проверяется по идентификатору).
/// </para>
/// </remarks>
public bool CanAcceptDrop(DropInfo dropInfo)
{
if (dropInfo.Data is not DockElementDragData dragData)
return false;
// Нельзя сбросить элемент на самого себя
if (dragData.ElementId == Id)
return false;
// Можно принимать только элементы док-системы
return dragData.ElementType == nameof(DockGroup) || dragData.ElementType == nameof(DockLeaf);
}
/// <summary>
/// Вызывается, когда перетаскиваемый объект находится над группой.
/// </summary>
/// <param name="dropInfo">
/// Информация о текущем положении перетаскивания.
/// </param>
/// <remarks>
/// <para>
/// Этот метод вызывается постоянно, пока пользователь перемещает объект
/// над целью. Для группы он устанавливает предлагаемые эффекты в
/// <see cref="DropInfo.SuggestedEffects"/>.
/// </para>
/// <para>
/// Если группа может принять сброс, предлагается эффект перемещения;
/// в противном случае эффекты не предлагаются.
/// </para>
/// </remarks>
public void DragOver(DropInfo dropInfo)
{
if (CanAcceptDrop(dropInfo))
{
dropInfo.SuggestedEffects = DragDropEffects.Move;
}
else
{
dropInfo.SuggestedEffects = DragDropEffects.None;
}
}
/// <summary>
/// Вызывается, когда пользователь сбрасывает данные на группу.
/// </summary>
/// <param name="dropInfo">
/// Информация о сбросе.
/// </param>
/// <remarks>
/// <para>
/// Для <see cref="DockGroup"/> обработка сброса делегируется
/// <see cref="LayoutManager"/>, поэтому метод просто помечает операцию
/// как обработанную.
/// </para>
/// <para>
/// Фактическое изменение структуры дерева выполняется менеджером макета
/// на основе данных из <paramref name="dropInfo"/>.
/// </para>
/// </remarks>
public void Drop(DropInfo dropInfo)
{
// Обработка сброса делегируется LayoutManager
dropInfo.MarkAsHandled();
}
/// <summary>
/// Вызывается, когда перетаскиваемый объект покидает область группы.
/// </summary>
/// <remarks>
/// Для группы этот метод не выполняет действий, так как очистка визуальной
/// обратной связи выполняется в UI-слое.
/// </remarks>
public void DragLeave()
{
// Очистка визуальной обратной связи (будет выполнена в UI слое)
}
#endregion
}

View File

@@ -0,0 +1,580 @@
using Lattice.Core.Docking.Abstractions;
using Lattice.Core.DragDrop.Abstractions;
using Lattice.Core.DragDrop.Enums;
using Lattice.Core.DragDrop.Models;
using Lattice.Core.Geometry;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Lattice.Core.Docking.Models;
/// <summary>
/// Представляет конечный узел (лист) дерева компоновки, который непосредственно
/// содержит коллекцию вкладок с контентом. Этот класс является контейнером для
/// отображаемого пользователю содержимого.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="DockLeaf"/> реализует интерфейсы <see cref="IDockContainer"/>,
/// <see cref="IDragSource"/> и <see cref="IDropTarget"/>, что позволяет ему:
/// </para>
/// <list type="bullet">
/// <item>Управлять коллекцией вкладок</item>
/// <item>Быть источником перетаскивания (как всего листа, так и отдельных вкладок)</item>
/// <item>Принимать сброс других элементов или вкладок</item>
/// </list>
/// <para>
/// Лист является основным элементом, с которым взаимодействует пользователь
/// при работе с документами или инструментальными панелями в IDE-подобных
/// приложениях.
/// </para>
/// </remarks>
public class DockLeaf : IDockContainer, INotifyPropertyChanged, IDragSource, IDropTarget
{
/// <summary>
/// Событие, возникающее при изменении значения свойства.
/// </summary>
public event PropertyChangedEventHandler? PropertyChanged;
private readonly ObservableCollection<IDockContent> _items = new();
private IDockContent? _activeContent;
private string _id;
/// <summary>
/// Получает уникальный идентификатор листа.
/// </summary>
/// <value>
/// Строковый идентификатор, уникальный в пределах дерева компоновки.
/// </value>
public string Id
{
get => _id;
internal set
{
if (_id != value)
{
_id = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// Получает или задает родительский элемент в иерархии дерева компоновки.
/// </summary>
/// <value>
/// Родительский элемент или null, если этот лист является корневым.
/// </value>
public IDockElement? Parent { get; set; }
/// <summary>
/// Получает список вкладок, содержащихся в данном контейнере.
/// </summary>
/// <value>
/// Коллекция объектов, реализующих <see cref="IDockContent"/>.
/// </value>
/// <remarks>
/// Эта коллекция является наблюдаемой (ObservableCollection), что позволяет
/// автоматически обновлять пользовательский интерфейс при добавлении или
/// удалении вкладок.
/// </remarks>
public IList<IDockContent> Children => _items;
/// <summary>
/// Получает или задает активную (выбранную) вкладку в контейнере.
/// </summary>
/// <value>
/// Активная вкладка или null, если в контейнере нет вкладок.
/// </value>
/// <remarks>
/// <para>
/// При установке нового значения проверяется, что вкладка действительно
/// содержится в коллекции <see cref="Children"/>.
/// </para>
/// <para>
/// Изменение этого свойства вызывает событие <see cref="PropertyChanged"/>.
/// </para>
/// </remarks>
public IDockContent? ActiveContent
{
get => _activeContent;
set
{
if (value != null && !_items.Contains(value)) return;
if (_activeContent != value)
{
_activeContent = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// Получает или задает желаемую ширину элемента.
/// </summary>
/// <value>
/// Ширина в пикселях или относительных единицах.
/// </value>
public double Width { get; set; }
/// <summary>
/// Получает или задает желаемую высоту элемента.
/// </summary>
/// <value>
/// Высота в пикселях или относительных единицах.
/// </value>
public double Height { get; set; }
/// <summary>
/// Получает или задает минимально допустимую ширину элемента.
/// </summary>
/// <value>
/// Минимальная ширина в пикселях. Значение по умолчанию: 100.
/// </value>
public double MinWidth { get; set; } = 100;
/// <summary>
/// Получает или задает минимально допустимую высоту элемента.
/// </summary>
/// <value>
/// Минимальная высота в пикселях. Значение по умолчанию: 100.
/// </value>
public double MinHeight { get; set; } = 100;
/// <summary>
/// Получает или задает положение полосы вкладок в контейнере.
/// </summary>
/// <value>
/// Значение перечисления <see cref="TabPlacement"/>, определяющее,
/// где располагаются вкладки относительно содержимого.
/// </value>
/// <remarks>
/// Поддерживаются все четыре стороны: верх, низ, лево, право.
/// </remarks>
public TabPlacement TabPlacement { get; set; } = TabPlacement.Bottom;
/// <summary>
/// Инициализирует новый экземпляр класса <see cref="DockLeaf"/>.
/// </summary>
/// <param name="id">
/// Уникальный идентификатор листа. Если не указан, генерируется новый GUID.
/// </param>
/// <remarks>
/// Создает пустой лист с коллекцией вкладок и генерирует уникальный
/// идентификатор, если он не был предоставлен.
/// </remarks>
public DockLeaf(string? id = null)
{
_id = id ?? Guid.NewGuid().ToString();
}
/// <summary>
/// Вызывает событие <see cref="PropertyChanged"/>.
/// </summary>
/// <param name="name">
/// Имя изменившегося свойства. Если не указано, определяется автоматически.
/// </param>
protected void OnPropertyChanged([CallerMemberName] string? name = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
/// <summary>
/// Добавляет контент в контейнер и делает его активным.
/// </summary>
/// <param name="content">
/// Контент для добавления.
/// </param>
/// <remarks>
/// <para>
/// Если контент уже содержится в коллекции, он не добавляется повторно,
/// но становится активным.
/// </para>
/// <para>
/// Этот метод обновляет свойство <see cref="ActiveContent"/> и вызывает
/// соответствующее событие изменения свойства.
/// </para>
/// </remarks>
public void AddContent(IDockContent content)
{
if (!_items.Contains(content))
{
_items.Add(content);
}
ActiveContent = content;
}
/// <summary>
/// Удаляет контент из контейнера.
/// </summary>
/// <param name="content">
/// Контент для удаления.
/// </param>
/// <remarks>
/// <para>
/// Если удаляемый контент является активным, автоматически выбирается
/// новая активная вкладка (следующая в списке или предыдущая, если удалена
/// последняя).
/// </para>
/// <para>
/// Если после удаления контейнер становится пустым, он может быть удален
/// из дерева макета системой компоновки.
/// </para>
/// </remarks>
public void RemoveContent(IDockContent content)
{
int index = _items.IndexOf(content);
if (index == -1) return;
_items.RemoveAt(index);
if (ActiveContent == content)
{
if (_items.Count > 0)
ActiveContent = _items[Math.Min(index, _items.Count - 1)];
else
ActiveContent = null;
}
}
#region Реализация IDragSource
/// <summary>
/// Определяет, может ли лист начать операцию перетаскивания.
/// </summary>
/// <param name="dragInfo">
/// При успешном возврате содержит информацию о перетаскивании;
/// в противном случае — null.
/// </param>
/// <returns>
/// true, если лист может начать перетаскивание; в противном случае — false.
/// </returns>
/// <remarks>
/// <para>
/// Лист может быть перетащен, если:
/// </para>
/// <list type="bullet">
/// <item>Он имеет родителя (не является корневым)</item>
/// <item>Или имеет хотя бы одну вкладку (не пустой)</item>
/// </list>
/// <para>
/// В зависимости от наличия активного контента создаются разные данные:
/// </para>
/// <list type="bullet">
/// <item>
/// Если есть активный контент - создается <see cref="ContentDragData"/>
/// для перетаскивания конкретной вкладки
/// </item>
/// <item>
/// Если нет активного контента - создается <see cref="DockElementDragData"/>
/// для перетаскивания всего листа
/// </item>
/// </list>
/// </remarks>
public bool CanStartDrag(out DragInfo? dragInfo)
{
dragInfo = null;
// DockLeaf можно перетаскивать
if (Parent == null && Children.Count == 0)
return false; // Не перетаскиваем пустые корневые листья
object data;
// Если есть активный контент, перетаскиваем контент, иначе перетаскиваем весь лист
if (ActiveContent != null)
{
data = new ContentDragData
{
ElementId = Id,
ContentId = ActiveContent.Id,
ContentTitle = ActiveContent.Title,
ContentType = ActiveContent.GetType().Name
};
}
else
{
data = new DockElementDragData
{
ElementId = Id,
ElementType = GetType().Name,
IsGroup = false,
Width = Width,
Height = Height
};
}
dragInfo = new DragInfo(data, DragDropEffects.Move | DragDropEffects.Copy, Point.Zero, this);
return true;
}
/// <summary>
/// Начинает операцию перетаскивания для листа.
/// </summary>
/// <param name="dragInfo">
/// Информация о перетаскивании.
/// </param>
/// <returns>
/// Всегда возвращает true.
/// </returns>
/// <remarks>
/// Для <see cref="DockLeaf"/> этот метод не выполняет дополнительных действий.
/// </remarks>
public bool StartDrag(DragInfo dragInfo)
{
// DockLeaf не требует дополнительной подготовки
return true;
}
/// <summary>
/// Вызывается при завершении операции перетаскивания.
/// </summary>
/// <param name="dragInfo">
/// Исходная информация о перетаскивании.
/// </param>
/// <param name="effects">
/// Эффекты, которые были применены при сбросе.
/// </param>
/// <remarks>
/// Для <see cref="DockLeaf"/> этот метод не выполняет действий.
/// </remarks>
public void DragCompleted(DragInfo dragInfo, DragDropEffects effects)
{
// Если лист был перемещен или скопирован, LayoutManager уже обработал это
}
/// <summary>
/// Вызывается при отмене операции перетаскивания.
/// </summary>
/// <param name="dragInfo">
/// Исходная информация о перетаскивании.
/// </param>
/// <remarks>
/// Для <see cref="DockLeaf"/> отмена перетаскивания не требует действий.
/// </remarks>
public void DragCancelled(DragInfo dragInfo)
{
// Отмена не требует действий
}
#endregion
#region Реализация IDropTarget
/// <summary>
/// Определяет, может ли лист принять сбрасываемые данные.
/// </summary>
/// <param name="dropInfo">
/// Информация о потенциальном сбросе.
/// </param>
/// <returns>
/// true, если лист может принять данные; в противном случае — false.
/// </returns>
/// <remarks>
/// Лист может принимать:
/// <list type="bullet">
/// <item>
/// <see cref="DockElementDragData"/> для других листов и групп
/// (для объединения или разделения)
/// </item>
/// <item>
/// <see cref="ContentDragData"/> для вкладок (для объединения вкладок)
/// </item>
/// </list>
/// </remarks>
public bool CanAcceptDrop(DropInfo dropInfo)
{
if (dropInfo.Data is DockElementDragData elementData)
{
// Можно принимать другие листы и группы
return elementData.ElementType == nameof(DockLeaf) ||
elementData.ElementType == nameof(DockGroup);
}
else if (dropInfo.Data is ContentDragData contentData)
{
// Можно принимать контент для объединения вкладок
return true;
}
return false;
}
/// <summary>
/// Вызывается, когда перетаскиваемый объект находится над листом.
/// </summary>
/// <param name="dropInfo">
/// Информация о текущем положении перетаскивания.
/// </param>
/// <remarks>
/// <para>
/// В зависимости от типа данных устанавливаются разные предлагаемые эффекты:
/// </para>
/// <list type="bullet">
/// <item>
/// Для <see cref="ContentDragData"/> - эффект копирования (объединение вкладок)
/// </item>
/// <item>
/// Для <see cref="DockElementDragData"/> - эффект перемещения
/// </item>
/// </list>
/// </remarks>
public void DragOver(DropInfo dropInfo)
{
if (CanAcceptDrop(dropInfo))
{
if (dropInfo.Data is ContentDragData)
{
// Для контента предлагаем копирование (объединение вкладок)
dropInfo.SuggestedEffects = DragDropEffects.Copy;
}
else
{
// Для элементов предлагаем перемещение
dropInfo.SuggestedEffects = DragDropEffects.Move;
}
}
else
{
dropInfo.SuggestedEffects = DragDropEffects.None;
}
}
/// <summary>
/// Вызывается, когда пользователь сбрасывает данные на лист.
/// </summary>
/// <param name="dropInfo">
/// Информация о сбросе.
/// </param>
/// <remarks>
/// Обработка сброса делегируется <see cref="LayoutManager"/>.
/// </remarks>
public void Drop(DropInfo dropInfo)
{
// Обработка делегируется LayoutManager
dropInfo.MarkAsHandled();
}
/// <summary>
/// Вызывается, когда перетаскиваемый объект покидает область листа.
/// </summary>
/// <remarks>
/// Очистка визуальной обратной связи выполняется в UI-слое.
/// </remarks>
public void DragLeave()
{
// Очистка визуальной обратной связи
}
#endregion
}
/// <summary>
/// Представляет данные для перетаскивания элементов док-системы (групп или листов).
/// Используется при перетаскивании целых структурных элементов дерева компоновки.
/// </summary>
/// <remarks>
/// Этот класс сериализуется и передается между компонентами системы перетаскивания
/// для идентификации перетаскиваемого элемента и его свойств.
/// </remarks>
public class DockElementDragData
{
/// <summary>
/// Получает или задает уникальный идентификатор элемента.
/// </summary>
/// <value>
/// Идентификатор элемента, соответствующий свойству <see cref="IDockElement.Id"/>.
/// </value>
public string ElementId { get; set; } = string.Empty;
/// <summary>
/// Получает или задает тип элемента.
/// </summary>
/// <value>
/// Имя типа элемента (обычно "DockGroup" или "DockLeaf").
/// </value>
public string ElementType { get; set; } = string.Empty;
/// <summary>
/// Получает или задает значение, указывающее, является ли элемент группой.
/// </summary>
/// <value>
/// true, если элемент является <see cref="DockGroup"/>; false, если <see cref="DockLeaf"/>.
/// </value>
public bool IsGroup { get; set; }
/// <summary>
/// Получает или задает идентификатор родительского элемента.
/// </summary>
/// <value>
/// Идентификатор родительского элемента или null, если элемент корневой.
/// </value>
public string? ParentId { get; set; }
/// <summary>
/// Получает или задает ширину элемента.
/// </summary>
/// <value>
/// Текущая ширина элемента в пикселях.
/// </value>
public double Width { get; set; }
/// <summary>
/// Получает или задает высоту элемента.
/// </summary>
/// <value>
/// Текущая высота элемента в пикселях.
/// </value>
public double Height { get; set; }
}
/// <summary>
/// Представляет данные для перетаскивания контента (вкладок).
/// Используется при перетаскивании отдельных вкладок между контейнерами.
/// </summary>
/// <remarks>
/// Этот класс позволяет идентифицировать конкретную вкладку для операций
/// объединения или перемещения между контейнерами.
/// </remarks>
public class ContentDragData
{
/// <summary>
/// Получает или задает идентификатор контейнера (листа), содержащего контент.
/// </summary>
/// <value>
/// Идентификатор <see cref="DockLeaf"/>, в котором находится перетаскиваемая вкладка.
/// </value>
public string ElementId { get; set; } = string.Empty;
/// <summary>
/// Получает или задает уникальный идентификатор контента.
/// </summary>
/// <value>
/// Идентификатор контента, соответствующий свойству <see cref="IDockContent.Id"/>.
/// </value>
public string ContentId { get; set; } = string.Empty;
/// <summary>
/// Получает или задает заголовок контента.
/// </summary>
/// <value>
/// Текст, отображаемый на вкладке.
/// </value>
public string ContentTitle { get; set; } = string.Empty;
/// <summary>
/// Получает или задает тип контента.
/// </summary>
/// <value>
/// Имя типа контента (например, "TextEditor", "Toolbox", и т.д.).
/// </value>
public string ContentType { get; set; } = string.Empty;
/// <summary>
/// Получает или задает значение, указывающее, можно ли закрыть контент.
/// </summary>
/// <value>
/// true, если контент можно закрыть; в противном случае — false.
/// </value>
public bool CanClose { get; set; } = true;
}

View File

@@ -0,0 +1,13 @@
namespace Lattice.Core.Docking.Models;
/// <summary>
/// Определяет позицию вставки при операции Drag-and-Drop.
/// </summary>
public enum DockPosition
{
Left,
Right,
Top,
Bottom,
Center,
}

View File

@@ -0,0 +1,19 @@
namespace Lattice.Core.Docking.Models;
/// <summary>
/// Определяет стороны окна, к которым могут быть прикреплены автоскрываемые панели.
/// </summary>
public enum DockSide
{
/// <summary> Левая сторона окна. </summary>
Left,
/// <summary> Правая сторона окна. </summary>
Right,
/// <summary> Верхняя сторона окна. </summary>
Top,
/// <summary> Нижняя сторона окна. </summary>
Bottom
}

View File

@@ -0,0 +1,23 @@
using Lattice.Core.Docking.Abstractions;
namespace Lattice.Core.Docking.Models;
/// <summary>
/// Описывает состояние плавающего окна в системе Lattice.
/// </summary>
public class DockWindow
{
/// <summary> Уникальный ID окна для сохранения его позиции в конфиге. </summary>
public string Id { get; } = Guid.NewGuid().ToString();
/// <summary> Корневой элемент макета внутри данного окна. </summary>
public IDockElement? Root { get; set; }
public double X { get; set; }
public double Y { get; set; }
public double Width { get; set; } = 800;
public double Height { get; set; } = 600;
/// <summary> Заголовок окна (обычно берется из активного контента). </summary>
public string Title { get; set; } = "Lattice Tool Window";
}

View File

@@ -0,0 +1,12 @@
namespace Lattice.Core.Docking.Models;
/// <summary>
/// Перечисление направлений разделения пространства внутри группы.
/// </summary>
public enum SplitDirection
{
/// <summary> Разделение по горизонтали (создает левую и правую области). </summary>
Horizontal,
/// <summary> Разделение по вертикали (создает верхнюю и нижнюю области). </summary>
Vertical
}

View File

@@ -0,0 +1,12 @@
namespace Lattice.Core.Docking.Models;
/// <summary>
/// Определяет положение полосы вкладок в контейнере.
/// </summary>
public enum TabPlacement
{
Top,
Bottom,
Left,
Right,
}

View File

@@ -0,0 +1,31 @@
namespace Lattice.Core.Docking.Serialization;
/// <summary>
/// Абстракция для сериализации и десериализации состояния макета док-системы.
/// Позволяет сохранять и восстанавливать расположение панелей, окон и их состояние.
/// </summary>
/// <remarks>
/// Эта абстракция позволяет реализовать различные форматы сериализации (JSON, XML, бинарный)
/// и различные хранилища (файлы, базы данных, облако) без изменения основной логики док-системы.
/// </remarks>
public interface ILayoutSerializer
{
/// <summary>
/// Сериализует состояние менеджера макета в строку.
/// </summary>
/// <param name="manager">Менеджер макета для сериализации.</param>
/// <returns>Строковое представление состояния макета.</returns>
string Serialize(Engine.LayoutManager manager);
/// <summary>
/// Десериализует состояние макета из строки и восстанавливает его в менеджере.
/// </summary>
/// <param name="manager">Менеджер макета для восстановления состояния.</param>
/// <param name="serializedLayout">Сериализованное состояние макета.</param>
/// <param name="contentResolver">
/// Функция разрешения контента по идентификатору, используемая для восстановления
/// ссылок на контент в десериализованном состоянии.
/// </param>
void Deserialize(Engine.LayoutManager manager, string serializedLayout,
Func<string, Abstractions.IDockContent?> contentResolver);
}

View File

@@ -0,0 +1,19 @@
namespace Lattice.Core.Docking.Serialization;
/// <summary>
/// Контракт для объектов, которые могут предоставлять состояние для сериализации.
/// </summary>
public interface ISerializableLayout
{
/// <summary>
/// Получает состояние для сериализации.
/// </summary>
/// <returns>Объект состояния, готовый к сериализации.</returns>
object GetSerializableState();
/// <summary>
/// Восстанавливает состояние из десериализованного объекта.
/// </summary>
/// <param name="state">Десериализованное состояние.</param>
void RestoreFromState(object state);
}

View File

@@ -0,0 +1,158 @@
namespace Lattice.Core.Docking.Services;
/// <summary>
/// Реестр типов содержимого, который позволяет создавать экземпляры контента по типу.
/// Этот сервис является центральным для динамического создания панелей инструментов и документов в IDE.
/// </summary>
/// <remarks>
/// Реализует шаблон "Фабрика" для создания экземпляров <see cref="Abstractions.IDockContent"/>.
/// Позволяет регистрировать фабричные методы для различных типов контента, что обеспечивает
/// позднее связывание и возможность плагинной архитектуры.
/// </remarks>
public class ContentRegistry
{
private readonly Dictionary<string, ContentDescriptor> _contentTypes = new();
/// <summary>
/// Регистрирует фабричный метод для создания контента указанного типа.
/// </summary>
/// <typeparam name="T">Тип контента, реализующий <see cref="Abstractions.IDockContent"/>.</typeparam>
/// <param name="contentTypeId">Уникальный идентификатор типа контента.</param>
/// <param name="factory">Фабричный метод для создания экземпляров контента.</param>
/// <param name="metadata">Метаданные типа контента (опционально).</param>
/// <exception cref="ArgumentNullException">Выбрасывается, если contentTypeId или factory равны null.</exception>
/// <exception cref="ArgumentException">Выбрасывается, если contentTypeId уже зарегистрирован.</exception>
public void Register<T>(string contentTypeId, Func<T> factory, ContentMetadata? metadata = null)
where T : Abstractions.IDockContent
{
if (string.IsNullOrWhiteSpace(contentTypeId))
throw new ArgumentNullException(nameof(contentTypeId));
if (factory == null)
throw new ArgumentNullException(nameof(factory));
if (_contentTypes.ContainsKey(contentTypeId))
throw new ArgumentException($"Content type '{contentTypeId}' is already registered.");
_contentTypes[contentTypeId] = new ContentDescriptor(
typeof(T),
() => factory(),
metadata ?? new ContentMetadata(contentTypeId, typeof(T).Name)
);
}
/// <summary>
/// Создает новый экземпляр контента указанного типа с заданным идентификатором.
/// </summary>
/// <param name="contentTypeId">Идентификатор типа контента.</param>
/// <param name="id">Уникальный идентификатор для создаваемого экземпляра контента.</param>
/// <returns>Новый экземпляр контента.</returns>
/// <exception cref="KeyNotFoundException">Выбрасывается, если тип контента не зарегистрирован.</exception>
public Abstractions.IDockContent CreateContent(string contentTypeId, string id)
{
if (!_contentTypes.TryGetValue(contentTypeId, out var descriptor))
throw new KeyNotFoundException($"Content type '{contentTypeId}' is not registered.");
var content = descriptor.Factory();
// Устанавливаем ID через рефлексию, если есть свойство Id
var property = content.GetType().GetProperty("Id");
if (property != null && property.CanWrite)
{
property.SetValue(content, id);
}
return content;
}
/// <summary>
/// Получает метаданные для указанного типа контента.
/// </summary>
/// <param name="contentTypeId">Идентификатор типа контента.</param>
/// <returns>Метаданные типа контента или null, если тип не найден.</returns>
public ContentMetadata? GetMetadata(string contentTypeId)
{
return _contentTypes.TryGetValue(contentTypeId, out var descriptor)
? descriptor.Metadata
: null;
}
/// <summary>
/// Получает все зарегистрированные типы контента.
/// </summary>
/// <returns>Коллекция идентификаторов зарегистрированных типов контента.</returns>
public IEnumerable<string> GetRegisteredTypes() => _contentTypes.Keys;
/// <summary>
/// Проверяет, зарегистрирован ли указанный тип контента.
/// </summary>
public bool IsRegistered(string contentTypeId) => _contentTypes.ContainsKey(contentTypeId);
/// <summary>
/// Дескриптор типа контента, содержащий информацию о фабричном методе и метаданных.
/// </summary>
private class ContentDescriptor
{
public Type ContentType { get; }
public Func<Abstractions.IDockContent> Factory { get; }
public ContentMetadata Metadata { get; }
public ContentDescriptor(Type contentType, Func<Abstractions.IDockContent> factory, ContentMetadata metadata)
{
ContentType = contentType;
Factory = factory;
Metadata = metadata;
}
}
}
/// <summary>
/// Метаданные типа контента, предоставляющие дополнительную информацию для отображения в UI.
/// </summary>
public class ContentMetadata
{
/// <summary>
/// Идентификатор типа контента.
/// </summary>
public string ContentTypeId { get; }
/// <summary>
/// Отображаемое имя типа контента.
/// </summary>
public string DisplayName { get; set; }
/// <summary>
/// Описание типа контента.
/// </summary>
public string Description { get; set; }
/// <summary>
/// Имя ресурса для иконки (опционально).
/// </summary>
public string? IconResource { get; set; }
/// <summary>
/// Признак того, что контент является документом (а не инструментальной панелью).
/// </summary>
public bool IsDocument { get; set; }
/// <summary>
/// Минимальная ширина контента в пикселях.
/// </summary>
public double DefaultWidth { get; set; } = 300;
/// <summary>
/// Минимальная высота контента в пикселях.
/// </summary>
public double DefaultHeight { get; set; } = 200;
/// <summary>
/// Инициализирует новый экземпляр метаданных контента.
/// </summary>
/// <param name="contentTypeId">Идентификатор типа контента.</param>
/// <param name="displayName">Отображаемое имя.</param>
public ContentMetadata(string contentTypeId, string displayName)
{
ContentTypeId = contentTypeId;
DisplayName = displayName;
Description = string.Empty;
}
}

View File

@@ -0,0 +1,115 @@
using Lattice.Core.DragDrop.Abstractions;
using Lattice.Core.DragDrop.Enums;
using Lattice.Core.DragDrop.Models;
using Lattice.Core.DragDrop.Services;
using Lattice.Core.Geometry;
using Moq;
using Xunit;
namespace Lattice.Core.DragDrop.Tests;
public class DragDropServiceTests
{
[Fact]
public void StartDrag_WithValidSource_StartsDragOperation()
{
// Arrange
var service = new DragDropService();
var mockSource = new Mock<IDragSource>();
var dragInfo = new DragInfo("test", DragDropEffects.Copy, new Point(0, 0));
mockSource.Setup(s => s.CanStartDrag(out dragInfo)).Returns(true);
mockSource.Setup(s => s.StartDrag(It.IsAny<DragInfo>())).Returns(true);
// Act
var result = service.StartDrag(mockSource.Object, new Point(0, 0));
// Assert
Assert.True(result);
Assert.True(service.IsDragActive);
Assert.NotNull(service.CurrentDragInfo);
}
[Fact]
public void RegisterDropTarget_ReturnsValidId()
{
// Arrange
var service = new DragDropService();
var mockTarget = new Mock<IDropTarget>();
var bounds = new Rect(0, 0, 100, 100);
// Act
var id = service.RegisterDropTarget(mockTarget.Object, bounds);
// Assert
Assert.NotNull(id);
Assert.NotEmpty(id);
}
[Fact]
public void UpdateDrag_WithValidDropTarget_CallsDragOver()
{
// Arrange
var service = new DragDropService();
var mockSource = new Mock<IDragSource>();
var mockTarget = new Mock<IDropTarget>();
var dragInfo = new DragInfo("test", DragDropEffects.Copy, new Point(0, 0));
mockSource.Setup(s => s.CanStartDrag(out dragInfo)).Returns(true);
mockSource.Setup(s => s.StartDrag(It.IsAny<DragInfo>())).Returns(true);
var targetId = service.RegisterDropTarget(mockTarget.Object, new Rect(0, 0, 100, 100));
service.StartDrag(mockSource.Object, new Point(0, 0));
// Act
service.UpdateDrag(new Point(50, 50));
// Assert
mockTarget.Verify(t => t.DragOver(It.IsAny<DropInfo>()), Times.AtLeastOnce());
}
[Fact]
public void EndDrag_WithValidDrop_CallsDrop()
{
// Arrange
var service = new DragDropService();
var mockSource = new Mock<IDragSource>();
var mockTarget = new Mock<IDropTarget>();
var dragInfo = new DragInfo("test", DragDropEffects.Copy, new Point(0, 0));
mockSource.Setup(s => s.CanStartDrag(out dragInfo)).Returns(true);
mockSource.Setup(s => s.StartDrag(It.IsAny<DragInfo>())).Returns(true);
service.RegisterDropTarget(mockTarget.Object, new Rect(0, 0, 100, 100));
service.StartDrag(mockSource.Object, new Point(0, 0));
service.UpdateDrag(new Point(50, 50));
// Act
var effects = service.EndDrag(new Point(50, 50));
// Assert
mockTarget.Verify(t => t.Drop(It.IsAny<DropInfo>()), Times.Once());
Assert.False(service.IsDragActive);
}
[Fact]
public void CancelDrag_WithActiveDrag_CallsDragCancelled()
{
// Arrange
var service = new DragDropService();
var mockSource = new Mock<IDragSource>();
var dragInfo = new DragInfo("test", DragDropEffects.Copy, new Point(0, 0));
mockSource.Setup(s => s.CanStartDrag(out dragInfo)).Returns(true);
mockSource.Setup(s => s.StartDrag(It.IsAny<DragInfo>())).Returns(true);
service.StartDrag(mockSource.Object, new Point(0, 0));
// Act
service.CancelDrag();
// Assert
mockSource.Verify(s => s.DragCancelled(It.IsAny<DragInfo>()), Times.Once());
Assert.False(service.IsDragActive);
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="xunit" Version="2.6.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lattice.Core.DragDrop\Lattice.Core.DragDrop.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,28 @@
namespace Lattice.Core.DragDrop.Abstractions;
/// <summary>
/// Определяет контракт для объектов, которые могут быть источником данных
/// в операции перетаскивания с поддержкой асинхронных операций.
/// </summary>
public interface IAsyncDragSource : IDragSource
{
/// <summary>
/// Определяет, может ли объект начать операцию перетаскивания (асинхронно).
/// </summary>
Task<(bool CanStart, Models.DragInfo? DragInfo)> CanStartDragAsync();
/// <summary>
/// Начинает операцию перетаскивания (асинхронно).
/// </summary>
Task<bool> StartDragAsync(Models.DragInfo dragInfo);
/// <summary>
/// Вызывается при завершении операции перетаскивания (асинхронно).
/// </summary>
Task DragCompletedAsync(Models.DragInfo dragInfo, Enums.DragDropEffects effects);
/// <summary>
/// Вызывается при отмене операции перетаскивания (асинхронно).
/// </summary>
Task DragCancelledAsync(Models.DragInfo dragInfo);
}

View File

@@ -0,0 +1,28 @@
namespace Lattice.Core.DragDrop.Abstractions;
/// <summary>
/// Определяет контракт для объектов, которые могут принимать сбрасываемые данные
/// в операции перетаскивания с поддержкой асинхронных операций.
/// </summary>
public interface IAsyncDropTarget : IDropTarget
{
/// <summary>
/// Определяет, может ли объект принять сбрасываемые данные (асинхронно).
/// </summary>
Task<bool> CanAcceptDropAsync(Models.DropInfo dropInfo);
/// <summary>
/// Вызывается, когда перетаскиваемый объект находится над целью (асинхронно).
/// </summary>
Task DragOverAsync(Models.DropInfo dropInfo);
/// <summary>
/// Вызывается, когда пользователь сбрасывает данные на цель (асинхронно).
/// </summary>
Task DropAsync(Models.DropInfo dropInfo);
/// <summary>
/// Вызывается, когда перетаскиваемый объект покидает область цели (асинхронно).
/// </summary>
Task DragLeaveAsync();
}

View File

@@ -0,0 +1,64 @@
namespace Lattice.Core.DragDrop.Abstractions;
/// <summary>
/// Определяет контракт для объектов, которые могут быть источником данных
/// в операции перетаскивания.
/// </summary>
/// <remarks>
/// Объекты, реализующие этот интерфейс, могут инициировать операции перетаскивания
/// и предоставлять данные для передачи другим элементам через механизм drag-and-drop.
/// </remarks>
public interface IDragSource
{
/// <summary>
/// Определяет, может ли объект начать операцию перетаскивания.
/// </summary>
/// <param name="dragInfo">
/// Информация о перетаскивании, которая будет заполнена данными, если операция разрешена.
/// </param>
/// <returns>
/// true, если объект может начать перетаскивание; в противном случае — false.
/// </returns>
/// <remarks>
/// Этот метод вызывается системой перетаскивания для проверки возможности
/// начала операции. Если метод возвращает true, он должен заполнить
/// <paramref name="dragInfo"/> необходимыми данными.
/// </remarks>
bool CanStartDrag(out Models.DragInfo? dragInfo);
/// <summary>
/// Начинает операцию перетаскивания.
/// </summary>
/// <param name="dragInfo">Информация о перетаскивании.</param>
/// <returns>
/// true, если операция перетаскивания успешно начата; в противном случае — false.
/// </returns>
/// <remarks>
/// Этот метод вызывается, когда пользователь начинает перетаскивание элемента.
/// Реализация должна подготовить данные для перетаскивания и, возможно,
/// создать визуальное представление перетаскиваемого объекта.
/// </remarks>
bool StartDrag(Models.DragInfo dragInfo);
/// <summary>
/// Вызывается при завершении операции перетаскивания.
/// </summary>
/// <param name="dragInfo">Исходная информация о перетаскивании.</param>
/// <param name="effects">Эффекты, которые были применены при сбросе.</param>
/// <remarks>
/// Этот метод вызывается после завершения операции перетаскивания
/// (успешного или неуспешного). Реализация может выполнить очистку
/// или обновить состояние на основе результата операции.
/// </remarks>
void DragCompleted(Models.DragInfo dragInfo, Enums.DragDropEffects effects);
/// <summary>
/// Вызывается при отмене операции перетаскивания.
/// </summary>
/// <param name="dragInfo">Исходная информация о перетаскивании.</param>
/// <remarks>
/// Этот метод вызывается, когда операция перетаскивания была отменена
/// пользователем (например, нажатием клавиши Escape).
/// </remarks>
void DragCancelled(Models.DragInfo dragInfo);
}

View File

@@ -0,0 +1,55 @@
namespace Lattice.Core.DragDrop.Abstractions;
/// <summary>
/// Определяет контракт для объектов, которые могут принимать сбрасываемые данные
/// в операции перетаскивания.
/// </summary>
/// <remarks>
/// Объекты, реализующие этот интерфейс, могут обрабатывать данные, сброшенные
/// пользователем, и предоставлять визуальную обратную связь во время перетаскивания.
/// </remarks>
public interface IDropTarget
{
/// <summary>
/// Определяет, может ли объект принять сбрасываемые данные.
/// </summary>
/// <param name="dropInfo">Информация о потенциальном сбросе.</param>
/// <returns>
/// true, если объект может принять данные; в противном случае — false.
/// </returns>
/// <remarks>
/// Этот метод вызывается, когда перетаскиваемый объект находится над целью.
/// Реализация должна проверить, совместимы ли данные с целью, и установить
/// предлагаемые эффекты в <paramref name="dropInfo"/>.
/// </remarks>
bool CanAcceptDrop(Models.DropInfo dropInfo);
/// <summary>
/// Вызывается, когда перетаскиваемый объект находится над целью.
/// </summary>
/// <param name="dropInfo">Информация о текущем положении перетаскивания.</param>
/// <remarks>
/// Этот метод вызывается постоянно, пока пользователь перемещает объект над целью.
/// Реализация может обновить визуальную обратную связь или изменить предлагаемые эффекты.
/// </remarks>
void DragOver(Models.DropInfo dropInfo);
/// <summary>
/// Вызывается, когда пользователь сбрасывает данные на цель.
/// </summary>
/// <param name="dropInfo">Информация о сбросе.</param>
/// <remarks>
/// Этот метод вызывается, когда пользователь отпускает кнопку мыши над целью.
/// Реализация должна обработать принятие данных и выполнить соответствующее действие.
/// </remarks>
void Drop(Models.DropInfo dropInfo);
/// <summary>
/// Вызывается, когда перетаскиваемый объект покидает область цели.
/// </summary>
/// <remarks>
/// Этот метод вызывается, когда пользователь перемещает объект за пределы цели.
/// Реализация должна очистить любую визуальную обратную связь, установленную ранее.
/// </remarks>
void DragLeave();
}

View File

@@ -0,0 +1,102 @@
namespace Lattice.Core.DragDrop.Enums;
/// <summary>
/// Определяет эффекты, которые могут быть применены при операции перетаскивания.
/// </summary>
/// <remarks>
/// Этот перечисление используется для указания допустимых операций перетаскивания
/// и передачи информации о результате операции между источником и целью.
/// </remarks>
[Flags]
public enum DragDropEffects
{
/// <summary>
/// Операция перетаскивания не разрешена.
/// </summary>
None = 0,
/// <summary>
/// Данные копируются из источника в цель.
/// </summary>
Copy = 1 << 0,
/// <summary>
/// Данные перемещаются из источника в цель.
/// </summary>
Move = 1 << 1,
/// <summary>
/// Создается ссылка на исходные данные.
/// </summary>
Link = 1 << 2,
/// <summary>
/// Целевой элемент может прокручиваться во время перетаскивания.
/// </summary>
Scroll = 1 << 3,
/// <summary>
/// Комбинированный эффект копирования и перемещения.
/// </summary>
CopyOrMove = Copy | Move,
/// <summary>
/// Все эффекты разрешены.
/// </summary>
All = Copy | Move | Link | Scroll
}
/// <summary>
/// Расширения для работы с DragDropEffects.
/// </summary>
public static class DragDropEffectsExtensions
{
/// <summary>
/// Проверяет, содержит ли эффекты указанный эффект.
/// </summary>
public static bool HasEffect(this DragDropEffects effects, DragDropEffects effect)
{
return (effects & effect) == effect;
}
/// <summary>
/// Проверяет, содержат ли эффекты копирование.
/// </summary>
public static bool CanCopy(this DragDropEffects effects)
{
return effects.HasEffect(DragDropEffects.Copy);
}
/// <summary>
/// Проверяет, содержат ли эффекты перемещение.
/// </summary>
public static bool CanMove(this DragDropEffects effects)
{
return effects.HasEffect(DragDropEffects.Move);
}
/// <summary>
/// Проверяет, содержат ли эффекты ссылку.
/// </summary>
public static bool CanLink(this DragDropEffects effects)
{
return effects.HasEffect(DragDropEffects.Link);
}
/// <summary>
/// Получает наиболее подходящий эффект на основе модификаторов клавиатуры.
/// </summary>
public static DragDropEffects GetEffectFromKeys(bool controlKey, bool shiftKey, bool altKey)
{
if (controlKey && altKey)
return DragDropEffects.Link;
if (controlKey)
return DragDropEffects.Copy;
if (shiftKey)
return DragDropEffects.Move;
if (altKey)
return DragDropEffects.Link;
return DragDropEffects.Move; // По умолчанию
}
}

View File

@@ -0,0 +1,14 @@
namespace Lattice.Core.DragDrop.Enums;
/// <summary>
/// Позиция сброса относительно цели.
/// </summary>
public enum DropPosition
{
Inside,
Top,
Bottom,
Left,
Right,
Center
}

View File

@@ -0,0 +1,85 @@
namespace Lattice.Core.DragDrop.Exceptions;
/// <summary>
/// Исключение, возникающее при ошибках в системе перетаскивания.
/// </summary>
public class DragDropException : Exception
{
/// <summary>
/// Код ошибки.
/// </summary>
public string ErrorCode { get; }
/// <summary>
/// Инициализирует новый экземпляр класса <see cref="DragDropException"/>.
/// </summary>
public DragDropException()
: base("Drag & Drop operation failed.")
{
ErrorCode = "DRAGDROP_0001";
}
/// <summary>
/// Инициализирует новый экземпляр класса <see cref="DragDropException"/> с указанным сообщением.
/// </summary>
public DragDropException(string message)
: base(message)
{
ErrorCode = "DRAGDROP_0002";
}
/// <summary>
/// Инициализирует новый экземпляр класса <see cref="DragDropException"/> с кодом ошибки.
/// </summary>
public DragDropException(string errorCode, string message)
: base(message)
{
ErrorCode = errorCode;
}
/// <summary>
/// Инициализирует новый экземпляр класса <see cref="DragDropException"/>
/// с указанным сообщением и внутренним исключением.
/// </summary>
public DragDropException(string message, Exception innerException)
: base(message, innerException)
{
ErrorCode = "DRAGDROP_0003";
}
/// <summary>
/// Инициализирует новый экземпляр класса <see cref="DragDropException"/>
/// с кодом ошибки, сообщением и внутренним исключением.
/// </summary>
public DragDropException(string errorCode, string message, Exception innerException)
: base(message, innerException)
{
ErrorCode = errorCode;
}
}
/// <summary>
/// Коды ошибок Drag & Drop системы.
/// </summary>
public static class DragDropErrorCodes
{
// Общие ошибки
public const string OperationAlreadyActive = "DRAGDROP_1001";
public const string OperationNotActive = "DRAGDROP_1002";
public const string InvalidData = "DRAGDROP_1003";
public const string Timeout = "DRAGDROP_1004";
// Ошибки источников
public const string SourceCannotDrag = "DRAGDROP_2001";
public const string SourceStartFailed = "DRAGDROP_2002";
// Ошибки целей
public const string TargetNotFound = "DRAGDROP_3001";
public const string TargetCannotAccept = "DRAGDROP_3002";
public const string TargetDropFailed = "DRAGDROP_3003";
// Ошибки системы
public const string SystemNotInitialized = "DRAGDROP_4001";
public const string SystemDisposed = "DRAGDROP_4002";
public const string MemoryAllocationFailed = "DRAGDROP_4003";
}

View File

@@ -0,0 +1,85 @@
namespace Lattice.Core.DragDrop.Extensions;
/// <summary>
/// Методы расширения для регистрации сервисов перетаскивания.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Добавляет сервис перетаскивания.
/// </summary>
/// <param name="serviceCollection">Коллекция сервисов.</param>
/// <returns>Коллекция сервисов.</returns>
/// <remarks>
/// Реализация DI должна быть предоставлена конкретным приложением.
/// </remarks>
public static object AddDragDropService(this object serviceCollection)
{
// Реализация регистрации сервиса должна быть в конкретном приложении
// Это абстрактный метод для поддержки DI без зависимостей
return serviceCollection;
}
/// <summary>
/// Добавляет сервис перетаскивания с конфигурацией.
/// </summary>
/// <param name="serviceCollection">Коллекция сервисов.</param>
/// <param name="configure">Действие конфигурации.</param>
/// <returns>Коллекция сервисов.</returns>
public static object AddDragDropService(
this object serviceCollection,
Action<DragDropServiceOptions> configure)
{
var options = new DragDropServiceOptions();
configure(options);
// Реализация регистрации с опциями должна быть в конкретном приложении
return serviceCollection;
}
}
/// <summary>
/// Опции конфигурации сервиса перетаскивания.
/// </summary>
public class DragDropServiceOptions
{
/// <summary>
/// Порог начала перетаскивания в пикселях.
/// </summary>
public double DragStartThreshold { get; set; } = 3.0;
/// <summary>
/// Включить ведение журнала операций.
/// </summary>
public bool EnableLogging { get; set; } = false;
/// <summary>
/// Включить автоматическую очистку неиспользуемых целей.
/// </summary>
public bool EnableAutoCleanup { get; set; } = true;
/// <summary>
/// Интервал автоматической очистки в миллисекундах.
/// </summary>
public int AutoCleanupInterval { get; set; } = 60000;
/// <summary>
/// Включить асинхронную обработку операций.
/// </summary>
public bool EnableAsyncOperations { get; set; } = true;
/// <summary>
/// Время ожидания асинхронных операций в миллисекундах.
/// </summary>
public int AsyncOperationTimeout { get; set; } = 5000;
/// <summary>
/// Включить сбор статистики.
/// </summary>
public bool EnableStatistics { get; set; } = true;
/// <summary>
/// Включить проверку типов данных.
/// </summary>
public bool EnableTypeChecking { get; set; } = true;
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageId>Lattice.Core.DragDrop</PackageId>
<Version>1.0.0</Version>
<Authors>FrigaT</Authors>
<Description>Professional drag-and-drop system for Lattice UI Framework</Description>
<PackageTags>ui;framework;drag;drop;docking;toolbox</PackageTags>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Lattice.Core.Geometry\Lattice.Core.Geometry.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,227 @@
using Lattice.Core.Geometry;
using System.Collections.Concurrent;
namespace Lattice.Core.DragDrop.Models;
/// <summary>
/// Содержит информацию о начале операции перетаскивания.
/// Этот класс передается от источника перетаскивания к системе перетаскивания
/// для инициализации и управления операцией.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="DragInfo"/> является ключевым компонентом системы перетаскивания,
/// инкапсулирующим все необходимые данные для начала операции. Он содержит:
/// </para>
/// <list type="bullet">
/// <item>Данные для передачи</item>
/// <item>Разрешенные эффекты перетаскивания</item>
/// <item>Начальную позицию операции</item>
/// <item>Ссылку на источник перетаскивания</item>
/// <item>Дополнительные параметры операции</item>
/// </list>
/// <para>
/// Этот класс используется как внутренний механизм передачи данных между
/// <see cref="Abstractions.IDragSource"/> и системой управления перетаскиванием.
/// </para>
/// </remarks>
public class DragInfo : IDisposable, ICloneable
{
private readonly ConcurrentDictionary<string, object> _parameters = new();
private bool _disposed;
/// <summary>
/// Получает данные, которые передаются в операции перетаскивания.
/// </summary>
/// <value>
/// Объект, содержащий данные для передачи. Может быть любого типа,
/// поддерживаемого системой перетаскивания.
/// </value>
/// <remarks>
/// Эти данные будут доступны цели сброса через <see cref="DropInfo.Data"/>.
/// Важно, чтобы данные были сериализуемыми, если операция перетаскивания
/// может выходить за пределы процесса приложения.
/// </remarks>
public object Data { get; }
/// <summary>
/// Получает разрешенные эффекты для этой операции перетаскивания.
/// </summary>
/// <value>
/// Комбинация флагов <see cref="Enums.DragDropEffects"/>, определяющая,
/// какие операции разрешены для этого перетаскивания.
/// </value>
/// <remarks>
/// Этот параметр используется системой для фильтрации допустимых операций
/// и предоставления соответствующей визуальной обратной связи пользователю.
/// </remarks>
public Enums.DragDropEffects AllowedEffects { get; }
/// <summary>
/// Получает начальную позицию операции перетаскивания в координатах экрана.
/// </summary>
/// <value>
/// Точка в экранных координатах, где была начата операция перетаскивания.
/// </value>
/// <remarks>
/// Эта позиция используется для вычисления смещения при создании визуального
/// представления перетаскивания и для определения порога начала операции.
/// </remarks>
public Point StartPosition { get; }
/// <summary>
/// Получает источник перетаскивания, который инициировал операцию.
/// </summary>
/// <value>
/// Объект, реализующий <see cref="Abstractions.IDragSource"/>, или null,
/// если источник не доступен или не требуется.
/// </value>
/// <remarks>
/// Эта ссылка может использоваться для уведомления источника о результате
/// операции перетаскивания (завершении или отмене).
/// </remarks>
public object? Source { get; }
/// <summary>
/// Получает или задает дополнительные параметры, специфичные для конкретной
/// реализации перетаскивания.
/// </summary>
/// <value>
/// Словарь, содержащий пары ключ-значение с дополнительными параметрами.
/// </value>
/// <remarks>
/// Используется для передачи контекстной информации, которая не входит
/// в стандартный набор свойств, но может быть полезной для обработки
/// операции перетаскивания.
/// </remarks>
public IReadOnlyDictionary<string, object> Parameters => _parameters;
/// <summary>
/// Инициализирует новый экземпляр класса <see cref="DragInfo"/>.
/// </summary>
/// <param name="data">
/// Данные, которые передаются в операции перетаскивания.
/// Не может быть null.
/// </param>
/// <param name="allowedEffects">
/// Разрешенные эффекты для этой операции перетаскивания.
/// </param>
/// <param name="startPosition">
/// Начальная позиция операции перетаскивания в координатах экрана.
/// </param>
/// <param name="source">
/// Источник перетаскивания, который инициировал операцию. Может быть null.
/// </param>
/// <exception cref="ArgumentNullException">
/// Выбрасывается, когда <paramref name="data"/> равен null.
/// </exception>
/// <remarks>
/// Конструктор создает экземпляр <see cref="DragInfo"/> с указанными
/// параметрами и инициализирует коллекцию параметров пустым словарем.
/// </remarks>
public DragInfo(object data, Enums.DragDropEffects allowedEffects, Point startPosition, object? source = null)
{
Data = data ?? throw new ArgumentNullException(nameof(data));
AllowedEffects = allowedEffects;
StartPosition = startPosition;
Source = source;
}
/// <summary>
/// Создает новый экземпляр <see cref="DragInfo"/> с теми же данными,
/// но новой позицией.
/// </summary>
/// <param name="newPosition">
/// Новая позиция для информации о перетаскивании.
/// </param>
/// <returns>
/// Новый экземпляр <see cref="DragInfo"/> с обновленной позицией.
/// </returns>
/// <remarks>
/// Этот метод используется для обновления информации о перетаскивании
/// при перемещении курсора, сохраняя исходные данные и параметры.
/// </remarks>
public DragInfo CloneWithPosition(Point newPosition)
{
ThrowIfDisposed();
var clone = new DragInfo(Data, AllowedEffects, newPosition, Source);
foreach (var kvp in _parameters)
{
clone._parameters[kvp.Key] = kvp.Value;
}
return clone;
}
/// <summary>
/// Создает новый экземпляр <see cref="DragInfo"/> с теми же данными.
/// </summary>
public DragInfo Clone() => new DragInfo(Data, AllowedEffects, StartPosition, Source);
/// <inheritdoc/>
object ICloneable.Clone() => this.Clone();
/// <summary>
/// Получает или дополнительные параметры, специфичные для конкретной
/// реализации перетаскивания.
/// </summary>
public T? GetParameter<T>(string key, T? defaultValue = default)
{
if (Parameters.TryGetValue(key, out var value) && value is T typedValue)
{
return typedValue;
}
return defaultValue;
}
/// <summary>
/// Получает или дополнительные параметры, специфичные для конкретной
/// реализации перетаскивания.
/// </summary>
public bool TryGetParameter<T>(string key, out T? value)
{
value = default;
if (_parameters.TryGetValue(key, out var objValue) && objValue is T typedValue)
{
value = typedValue;
return true;
}
return false;
}
/// <summary>
/// Задает дополнительные параметры, специфичные для конкретной
/// реализации перетаскивания.
/// </summary>
public void SetParameter<T>(string key, T value)
{
_parameters[key] = value!;
}
/// <summary>
/// Освобождает ресурсы.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_parameters.Clear();
_disposed = true;
GC.SuppressFinalize(this);
}
private void ThrowIfDisposed()
{
if (_disposed)
throw new ObjectDisposedException(nameof(DragInfo));
}
~DragInfo()
{
Dispose();
}
}

View File

@@ -0,0 +1,269 @@
using Lattice.Core.DragDrop.Enums;
using Lattice.Core.Geometry;
namespace Lattice.Core.DragDrop.Models;
/// <summary>
/// Содержит информацию о потенциальном или фактическом сбросе в операции перетаскивания.
/// Этот класс используется для передачи данных между системой перетаскивания
/// и целью сброса (<see cref="Abstractions.IDropTarget"/>).
/// </summary>
/// <remarks>
/// <para>
/// <see cref="DropInfo"/> предоставляет цель сброса всей необходимой информацией
/// для принятия решения о возможности сброса и выполнения соответствующей операции.
/// Ключевые аспекты включают:
/// </para>
/// <list type="bullet">
/// <item>Предлагаемые для сброса данные</item>
/// <item>Текущую позицию курсора</item>
/// <item>Разрешенные эффекты от источника</item>
/// <item>Предлагаемые эффекты для сброса</item>
/// <item>Ссылку на цель сброса</item>
/// <item>Флаг обработки операции</item>
/// </list>
/// <para>
/// Этот класс является изменяемым, позволяя цели сброса обновлять предлагаемые
/// эффекты и помечать операцию как обработанную.
/// </para>
/// </remarks>
public class DropInfo
{
private DragDropEffects _effects = DragDropEffects.None;
public DropPosition DropPosition { get; set; } = DropPosition.Inside;
public bool ShowVisualFeedback { get; set; } = true;
public object? VisualFeedbackData { get; set; }
/// <summary>
/// Получает данные, которые предлагаются для сброса.
/// </summary>
/// <value>
/// Данные, переданные от источника перетаскивания, или null, если данные
/// не доступны или операция была отменена.
/// </value>
/// <remarks>
/// Эти данные соответствуют свойству <see cref="DragInfo.Data"/> из
/// исходной информации о перетаскивании.
/// </remarks>
public object? Data { get; }
/// <summary>
/// Получает текущую позицию курсора в координатах экрана.
/// </summary>
/// <value>
/// Точка в экранных координатах, представляющая текущее положение курсора
/// мыши во время операции перетаскивания.
/// </value>
/// <remarks>
/// Эта позиция используется для определения точного места сброса и может
/// влиять на предлагаемые эффекты (например, различные операции для
/// разных областей цели сброса).
/// </remarks>
public Point Position { get; }
/// <summary>
/// Получает разрешенные эффекты от источника перетаскивания.
/// </summary>
/// <value>
/// Комбинация флагов <see cref="Enums.DragDropEffects"/>, определяющая,
/// какие операции разрешил источник.
/// </value>
/// <remarks>
/// Цель сброса должна уважать эти ограничения и не предлагать эффекты,
/// которые не разрешены источником.
/// </remarks>
public Enums.DragDropEffects AllowedEffects { get; }
/// <summary>
/// Получает или задает предлагаемые эффекты для операции сброса.
/// </summary>
/// <value>
/// Комбинация флагов <see cref="Enums.DragDropEffects"/>, предлагаемая
/// целью сброса. По умолчанию равно <see cref="Enums.DragDropEffects.None"/>.
/// </value>
/// <remarks>
/// <para>
/// Цель сброса должна установить это свойство в методе <see cref="Abstractions.IDropTarget.DragOver"/>
/// на основе анализа предоставленных данных и текущего контекста.
/// </para>
/// <para>
/// Если цель не устанавливает это свойство, система перетаскивания
/// будет использовать эффекты по умолчанию.
/// </para>
/// </remarks>
public Enums.DragDropEffects SuggestedEffects
{
get => _effects;
set => _effects = value;
}
/// <summary>
/// Получает цель сброса, которая обрабатывает эту информацию.
/// </summary>
/// <value>
/// Объект, реализующий <see cref="Abstractions.IDropTarget"/>, или null,
/// если цель не определена.
/// </value>
/// <remarks>
/// Эта ссылка позволяет системе идентифицировать, какая цель обрабатывает
/// информацию о сбросе, и используется для отслеживания изменений цели
/// во время операции перетаскивания.
/// </remarks>
public object? Target { get; }
/// <summary>
/// Получает или задает дополнительные параметры, специфичные для конкретной
/// реализации перетаскивания.
/// </summary>
/// <value>
/// Словарь, содержащий пары ключ-значение с дополнительными параметрами.
/// </value>
/// <remarks>
/// Может использоваться для передачи контекстной информации между
/// различными компонентами системы перетаскивания или для хранения
/// временных данных во время обработки операции.
/// </remarks>
public Dictionary<string, object> Parameters { get; set; }
/// <summary>
/// Получает значение, указывающее, был ли сброс уже обработан.
/// </summary>
/// <value>
/// true, если операция сброса была помечена как обработанная;
/// в противном случае — false.
/// </value>
/// <remarks>
/// <para>
/// Это свойство используется для предотвращения множественной обработки
/// одной и той же операции сброса. После вызова метода <see cref="MarkAsHandled"/>,
/// свойство становится true.
/// </para>
/// <para>
/// Система перетаскивания может проверять это свойство, чтобы определить,
/// нужно ли выполнять дополнительную обработку по умолчанию.
/// </para>
/// </remarks>
public bool Handled { get; private set; }
/// <summary>
/// Получает дополнительные параметры, специфичные для конкретной
/// реализации перетаскивания.
/// </summary>
public T? GetParameter<T>(string key, T? defaultValue = default)
{
if (Parameters.TryGetValue(key, out var value) && value is T typedValue)
{
return typedValue;
}
return defaultValue;
}
/// <summary>
/// Получает дополнительные параметры, специфичные для конкретной
/// реализации перетаскивания.
/// </summary>
public bool TryGetParameter<T>(string key, out T? value)
{
value = default;
if (Parameters.TryGetValue(key, out var objValue) && objValue is T typedValue)
{
value = typedValue;
return true;
}
return false;
}
/// <summary>
/// Задает дополнительные параметры, специфичные для конкретной
/// реализации перетаскивания.
/// </summary>
public void SetParameter<T>(string key, T value)
{
Parameters[key] = value!;
}
/// <summary>
/// Инициализирует новый экземпляр класса <see cref="DropInfo"/>.
/// </summary>
/// <param name="data">
/// Данные, которые предлагаются для сброса. Может быть null.
/// </param>
/// <param name="position">
/// Текущая позиция курсора в координатах экрана.
/// </param>
/// <param name="allowedEffects">
/// Разрешенные эффекты от источника перетаскивания.
/// </param>
/// <param name="target">
/// Цель сброса, которая обрабатывает эту информацию. Может быть null.
/// </param>
/// <remarks>
/// Конструктор создает экземпляр <see cref="DropInfo"/> с указанными
/// параметрами, инициализирует коллекцию параметров пустым словарем
/// и устанавливает флаг <see cref="Handled"/> в false.
/// </remarks>
public DropInfo(object? data, Point position, Enums.DragDropEffects allowedEffects, object? target = null)
{
Data = data;
Position = position;
AllowedEffects = allowedEffects;
Target = target;
Parameters = new Dictionary<string, object>();
Handled = false;
}
/// <summary>
/// Помечает сброс как обработанный.
/// </summary>
/// <remarks>
/// <para>
/// Этот метод должен вызываться целью сброса в методе <see cref="Abstractions.IDropTarget.Drop"/>,
/// если она успешно обработала операцию сброса.
/// </para>
/// <para>
/// После вызова этого метода свойство <see cref="Handled"/> становится true,
/// что сигнализирует системе перетаскивания о том, что дополнительная
/// обработка не требуется.
/// </para>
/// </remarks>
public void MarkAsHandled()
{
Handled = true;
}
/// <summary>
/// Создает новый экземпляр <see cref="DropInfo"/> с теми же данными,
/// но новой позицией.
/// </summary>
/// <param name="newPosition">
/// Новая позиция для информации о сбросе.
/// </param>
/// <returns>
/// Новый экземпляр <see cref="DropInfo"/> с обновленной позицией.
/// </returns>
/// <remarks>
/// Этот метод используется для обновления информации о сбросе при
/// перемещении курсора, сохраняя исходные данные и параметры.
/// </remarks>
public DropInfo WithPosition(Point newPosition)
{
return new DropInfo(Data, newPosition, AllowedEffects, Target)
{
Parameters = new Dictionary<string, object>(Parameters),
SuggestedEffects = _effects,
DropPosition = DropPosition,
ShowVisualFeedback = ShowVisualFeedback,
VisualFeedbackData = VisualFeedbackData
};
}
/// <summary>
/// Проверка установки эффекта перетаскивания в разрешенные эффекты.
/// </summary>
public bool CanAcceptEffect(Enums.DragDropEffects effect)
{
return (AllowedEffects & effect) != Enums.DragDropEffects.None;
}
}

View File

@@ -0,0 +1,832 @@
# Lattice.Core.DragDrop
Профессиональная, асинхронная система перетаскивания для .NET приложений. Полностью потокобезопасная, расширяемая архитектура с поддержкой кросс-платформенности.
## 📋 Особенности
-**Полная асинхронная поддержка** - async/await для всех операций
-**Потокобезопасность** - `ReaderWriterLockSlim` для эффективной синхронизации
-**Производительность** - Оптимизированные алгоритмы, кэширование, минимальные аллокации
-**Расширяемость** - Легкая интеграция с любыми UI фреймворками
-**Надежность** - Таймауты, обработка ошибок, корректное освобождение ресурсов
-**Статистика** - Встроенный мониторинг производительности
## 🏗️ Архитектура
### Основные компоненты
```
Lattice.Core.DragDrop/
├── Abstractions/ # Интерфейсы
│ ├── IDragSource.cs # Источник перетаскивания (синхронный)
│ ├── IAsyncDragSource.cs # Асинхронный источник
│ ├── IDropTarget.cs # Цель сброса (синхронная)
│ └── IAsyncDropTarget.cs # Асинхронная цель
├── Enums/ # Перечисления
├── Exceptions/ # Исключения с кодами ошибок
├── Extensions/ # Расширения для DI
├── Models/ # Модели данных
│ ├── DragInfo.cs # Информация о перетаскивании
│ └── DropInfo.cs # Информация о сбросе
├── Services/ # Сервисы
│ ├── IDragDropService.cs # Основной интерфейс
│ ├── DragDropService.cs # Реализация сервиса
│ └── EventArgs/ # Аргументы событий
└── Utilities/ # Утилиты и фабрики
├── DragDropUtilities.cs # Синхронные утилиты
└── AsyncDragDropUtilities.cs # Асинхронные утилиты
```
## 🚀 Быстрый старт
### 1. Установка
```csharp
// Добавьте проект Lattice.Core.DragDrop в ваше решение
// или создайте NuGet пакет
```
### 2. Базовое использование
```csharp
using Lattice.Core.DragDrop;
using Lattice.Core.DragDrop.Abstractions;
using Lattice.Core.DragDrop.Services;
using Lattice.Core.Geometry;
// Создаем сервис
var dragDropService = new DragDropService();
// Создаем простой источник перетаскивания
var dragSource = DragDropUtilities.CreateSimpleDragSource(
dataProvider: () => "Example Data",
canDrag: () => true,
onCompleted: (dragInfo, effects) =>
Console.WriteLine($"Drag completed with effects: {effects}"),
onCancelled: dragInfo =>
Console.WriteLine("Drag cancelled")
);
// Создаем простую цель сброса
var dropTarget = DragDropUtilities.CreateSimpleDropTarget(
canAccept: dropInfo => dropInfo.Data is string,
onDragOver: dropInfo =>
dropInfo.SuggestedEffects = DragDropEffects.Copy,
onDrop: dropInfo =>
{
Console.WriteLine($"Dropped: {dropInfo.Data}");
dropInfo.MarkAsHandled();
}
);
// Регистрируем цель
string targetId = dragDropService.RegisterDropTarget(
dropTarget,
new Rect(100, 100, 300, 200)
);
// Начинаем перетаскивание
bool started = dragDropService.StartDrag(
dragSource,
new Point(50, 50)
);
if (started)
{
// Обновляем позицию
dragDropService.UpdateDrag(new Point(150, 150));
// Завершаем
var effects = dragDropService.EndDrag(new Point(200, 200));
}
```
## 📖 Подробное руководство
### Сервис перетаскивания
Основной класс системы - `DragDropService`, реализующий `IDragDropService`.
```csharp
// Создание с кастомными настройками
var service = new DragDropService(options =>
{
options.DragStartThreshold = 5.0;
options.EnableAsyncOperations = true;
options.AsyncOperationTimeout = 3000;
options.EnableAutoCleanup = true;
});
// Свойства
bool isActive = service.IsDragActive; // Активна ли операция
DragInfo? currentDrag = service.CurrentDragInfo; // Текущая информация
double threshold = service.DragStartThreshold; // Порог начала
// События
service.DragStarted += OnDragStarted;
service.DragUpdated += OnDragUpdated;
service.DragCompleted += OnDragCompleted;
service.DragCancelled += OnDragCancelled;
service.ErrorOccurred += OnErrorOccurred;
// Регистрация целей
string id = service.RegisterDropTarget(
target, // IDropTarget
bounds, // Rect
priority: 1, // Приоритет (выше = выше приоритет)
group: "main" // Группа для группового удаления
);
// Обновление границ
service.UpdateDropTargetBounds(id, newBounds);
// Удаление
service.UnregisterDropTarget(id);
service.UnregisterDropTargetsInGroup("main");
```
### Асинхронное использование
```csharp
// Асинхронные методы
bool started = await service.StartDragAsync(source, startPosition);
await service.UpdateDragAsync(currentPosition);
DragDropEffects effects = await service.EndDragAsync(dropPosition);
await service.CancelDragAsync();
// Статистика
var stats = service.GetStats();
Console.WriteLine($"Operations: {stats.TotalDragOperations}");
Console.WriteLine($"Success rate: {stats.SuccessfulDrops}/{stats.TotalDragOperations}");
Console.WriteLine($"Avg time: {stats.AverageOperationTime.TotalMilliseconds}ms");
```
### Создание кастомных источников и целей
#### Синхронная реализация
```csharp
public class FileDragSource : IDragSource
{
private readonly FileInfo _file;
public FileDragSource(FileInfo file) => _file = file;
public bool CanStartDrag(out DragInfo? dragInfo)
{
// Проверяем условия
if (!_file.Exists || _file.Length > 100 * 1024 * 1024) // 100 MB limit
{
dragInfo = null;
return false;
}
// Создаем DragInfo
dragInfo = new DragInfo(
data: _file,
allowedEffects: DragDropEffects.Copy | DragDropEffects.Move,
startPosition: Point.Zero,
source: this
);
// Добавляем дополнительные параметры
dragInfo.SetParameter("FileSize", _file.Length);
dragInfo.SetParameter("MimeType", GetMimeType(_file));
return true;
}
public bool StartDrag(DragInfo dragInfo)
{
// Подготовка к перетаскиванию
// Можно создать визуальное представление и т.д.
Console.WriteLine($"Starting drag of {_file.Name}");
return true;
}
public void DragCompleted(DragInfo dragInfo, DragDropEffects effects)
{
Console.WriteLine($"File drag completed with {effects}");
if (effects == DragDropEffects.Move)
{
// Файл был перемещен - возможно, удалить оригинал
// _file.Delete();
}
}
public void DragCancelled(DragInfo dragInfo)
{
Console.WriteLine("File drag cancelled");
}
}
```
#### Асинхронная реализация
```csharp
public class DatabaseItemDragSource : IAsyncDragSource
{
private readonly DatabaseService _db;
private readonly int _itemId;
public async Task<(bool CanStart, DragInfo? DragInfo)> CanStartDragAsync()
{
try
{
// Асинхронные проверки
var canDrag = await _db.CanDragItemAsync(_itemId);
if (!canDrag) return (false, null);
// Асинхронная загрузка данных
var data = await _db.GetItemForDragAsync(_itemId);
if (data == null) return (false, null);
var dragInfo = new DragInfo(
data: data,
allowedEffects: DragDropEffects.Copy | DragDropEffects.Move,
startPosition: Point.Zero,
source: this
);
return (true, dragInfo);
}
catch (Exception ex)
{
// Логирование ошибки
return (false, null);
}
}
public Task<bool> StartDragAsync(DragInfo dragInfo)
{
// Асинхронная подготовка
return Task.FromResult(true);
}
public async Task DragCompletedAsync(DragInfo dragInfo, DragDropEffects effects)
{
// Асинхронная обработка завершения
await _db.LogDragOperationAsync(_itemId, effects);
if (effects == DragDropEffects.Move)
{
await _db.MarkItemAsMovedAsync(_itemId);
}
}
public Task DragCancelledAsync(DragInfo dragInfo)
{
return Task.CompletedTask;
}
// Синхронные методы для совместимости
public bool CanStartDrag(out DragInfo? dragInfo)
{
var result = Task.Run(() => CanStartDragAsync()).GetAwaiter().GetResult();
dragInfo = result.DragInfo;
return result.CanStart;
}
// ... остальные синхронные методы
}
```
### Работа с моделями данных
#### DragInfo
```csharp
// Создание
var dragInfo = new DragInfo(
data: myObject,
allowedEffects: DragDropEffects.Copy | DragDropEffects.Move,
startPosition: new Point(x, y),
source: this
);
// Параметры
dragInfo.SetParameter("Timestamp", DateTime.UtcNow);
dragInfo.SetParameter("UserId", currentUser.Id);
// Получение параметров
if (dragInfo.TryGetParameter<string>("Category", out var category))
{
// Используем категорию
}
// Клонирование с новой позицией
var updatedDragInfo = dragInfo.CloneWithPosition(newPosition);
// Очистка ресурсов
dragInfo.Dispose();
```
#### DropInfo
```csharp
// Создается сервисом автоматически
// Работа с DropInfo в методах цели:
public void DragOver(DropInfo dropInfo)
{
// Проверяем данные
if (dropInfo.Data is MyDataType myData)
{
// Определяем позицию относительно цели
dropInfo.DropPosition = CalculateDropPosition(dropInfo.Position);
// Предлагаем эффекты
if (CanAcceptData(myData))
{
dropInfo.SuggestedEffects = DragDropEffects.Move;
dropInfo.ShowVisualFeedback = true;
dropInfo.VisualFeedbackData = CreatePreview(myData);
}
else
{
dropInfo.SuggestedEffects = DragDropEffects.None;
}
}
}
public void Drop(DropInfo dropInfo)
{
if (dropInfo.Data is MyDataType myData)
{
// Обработка сброса
ProcessDrop(myData, dropInfo.DropPosition);
// Помечаем как обработанное
dropInfo.MarkAsHandled();
}
}
```
### Утилиты и фабрики
#### Синхронные утилиты
```csharp
// Простые реализации
var simpleSource = DragDropUtilities.CreateSimpleDragSource(
() => data,
() => true,
(dragInfo, effects) => Console.WriteLine("Completed"),
dragInfo => Console.WriteLine("Cancelled")
);
var simpleTarget = DragDropUtilities.CreateSimpleDropTarget(
dropInfo => dropInfo.Data != null,
dropInfo => dropInfo.SuggestedEffects = DragDropEffects.Copy,
dropInfo => Console.WriteLine($"Dropped: {dropInfo.Data}"),
() => Console.WriteLine("Drag left")
);
// Геометрия
double distance = DragDropUtilities.CalculateDistance(p1, p2);
bool exceeded = DragDropUtilities.HasExceededDragThreshold(start, current, threshold);
DropPosition position = DragDropUtilities.GetDropPosition(point, bounds, edgeThreshold);
// Проверка совместимости
bool compatible = DragDropUtilities.AreEffectsCompatible(sourceEffects, targetEffects);
bool typeMatch = DragDropUtilities.IsDataCompatible(data, new[] { typeof(string), typeof(int) });
```
#### Асинхронные утилиты
```csharp
// Асинхронные реализации
var asyncSource = AsyncDragDropUtilities.CreateAsyncDragSource(
async () => await LoadDataAsync(),
async () => await CanDragAsync(),
async (dragInfo, effects) => await OnCompletedAsync(dragInfo, effects),
async dragInfo => await OnCancelledAsync(dragInfo)
);
var asyncTarget = AsyncDragDropUtilities.CreateAsyncDropTarget(
async dropInfo => await CanAcceptAsync(dropInfo.Data),
async dropInfo => await OnDragOverAsync(dropInfo),
async dropInfo => await OnDropAsync(dropInfo),
async () => await OnDragLeaveAsync()
);
// Адаптеры для синхронных интерфейсов
IAsyncDragSource asyncFromSync = AsyncDragDropUtilities.CreateAsyncAdapter(syncSource);
IAsyncDropTarget asyncTargetFromSync = AsyncDragDropUtilities.CreateAsyncAdapter(syncTarget);
// Комбинированные реализации (fallback стратегия)
var combined = AsyncDragDropUtilities.Combine(
syncSource,
asyncSource,
preferAsync: true // При ошибке в async использует sync
);
// Таймауты
var result = await AsyncDragDropUtilities.ExecuteWithTimeoutAsync(
task: LongOperationAsync(),
timeout: TimeSpan.FromSeconds(5),
defaultValue: fallbackValue
);
```
### Обработка ошибок
```csharp
// Подписка на ошибки
service.ErrorOccurred += (sender, e) =>
{
Console.WriteLine($"Error in {e.Operation}: {e.Exception.Message}");
// Коды ошибок определены в DragDropErrorCodes
switch (e.ErrorCode)
{
case DragDropErrorCodes.Timeout:
Console.WriteLine("Operation timed out");
break;
case DragDropErrorCodes.SourceCannotDrag:
Console.WriteLine("Source cannot drag");
break;
case DragDropErrorCodes.TargetCannotAccept:
Console.WriteLine("Target cannot accept");
break;
}
};
// Использование в коде
try
{
await service.StartDragAsync(source, position);
}
catch (DragDropException ex)
{
// Обработка специфичных для DragDrop ошибок
Console.WriteLine($"DragDrop error {ex.ErrorCode}: {ex.Message}");
}
catch (Exception ex)
{
// Обработка других ошибок
Console.WriteLine($"General error: {ex.Message}");
}
```
## 🔧 Интеграция с UI фреймворками
### Базовый адаптер для WinUI/WPF
```csharp
public class UIElementDragSource : IAsyncDragSource
{
private readonly FrameworkElement _element;
private readonly Func<object> _dataProvider;
public UIElementDragSource(FrameworkElement element, Func<object> dataProvider)
{
_element = element;
_dataProvider = dataProvider;
// Подписка на события
_element.PointerPressed += OnPointerPressed;
_element.PointerMoved += OnPointerMoved;
_element.PointerReleased += OnPointerReleased;
}
private Point _dragStartPosition;
private bool _isDragging;
private void OnPointerPressed(object sender, PointerRoutedEventArgs e)
{
var point = e.GetCurrentPoint(_element);
_dragStartPosition = new Point(point.Position.X, point.Position.Y);
}
private async void OnPointerMoved(object sender, PointerRoutedEventArgs e)
{
if (_isDragging) return;
var point = e.GetCurrentPoint(_element);
var current = new Point(point.Position.X, point.Position.Y);
var distance = Math.Sqrt(
Math.Pow(current.X - _dragStartPosition.X, 2) +
Math.Pow(current.Y - _dragStartPosition.Y, 2));
if (distance > 3.0) // Порог
{
_isDragging = true;
// Начинаем перетаскивание через сервис
var service = GetDragDropService();
await service.StartDragAsync(this, ConvertToScreen(_dragStartPosition));
}
}
public async Task<(bool CanStart, DragInfo? DragInfo)> CanStartDragAsync()
{
var data = _dataProvider();
if (data == null) return (false, null);
var dragInfo = new DragInfo(
data,
DragDropEffects.Copy | DragDropEffects.Move,
Point.Zero,
this
);
return (true, dragInfo);
}
// ... остальная реализация
}
```
## 🧪 Тестирование
### Примеры модульных тестов
```csharp
[TestClass]
public class DragDropServiceTests
{
private DragDropService _service;
private Mock<IAsyncDragSource> _mockSource;
private Mock<IAsyncDropTarget> _mockTarget;
[TestInitialize]
public void Setup()
{
_service = new DragDropService();
_mockSource = new Mock<IAsyncDragSource>();
_mockTarget = new Mock<IAsyncDropTarget>();
}
[TestMethod]
public async Task StartDrag_ValidSource_ReturnsTrue()
{
// Arrange
var dragInfo = new DragInfo("test", DragDropEffects.Copy, Point.Zero);
_mockSource.Setup(s => s.CanStartDragAsync())
.ReturnsAsync((true, dragInfo));
_mockSource.Setup(s => s.StartDragAsync(It.IsAny<DragInfo>()))
.ReturnsAsync(true);
// Act
var result = await _service.StartDragAsync(_mockSource.Object, Point.Zero);
// Assert
Assert.IsTrue(result);
Assert.IsTrue(_service.IsDragActive);
}
[TestMethod]
public async Task UpdateDrag_FindsTarget_CallsDragOver()
{
// Arrange
var targetId = _service.RegisterDropTarget(
_mockTarget.Object,
new Rect(0, 0, 100, 100)
);
await StartTestDrag();
_mockTarget.Setup(t => t.CanAcceptDropAsync(It.IsAny<DropInfo>()))
.ReturnsAsync(true);
// Act
await _service.UpdateDragAsync(new Point(50, 50));
// Assert
_mockTarget.Verify(t => t.DragOverAsync(It.IsAny<DropInfo>()), Times.Once);
}
private async Task StartTestDrag()
{
var dragInfo = new DragInfo("test", DragDropEffects.Copy, Point.Zero);
_mockSource.Setup(s => s.CanStartDragAsync())
.ReturnsAsync((true, dragInfo));
_mockSource.Setup(s => s.StartDragAsync(It.IsAny<DragInfo>()))
.ReturnsAsync(true);
await _service.StartDragAsync(_mockSource.Object, Point.Zero);
}
}
```
## 📊 Мониторинг и производительность
### Сбор статистики
```csharp
// Получение статистики
var stats = service.GetStats();
Console.WriteLine($"Total operations: {stats.TotalDragOperations}");
Console.WriteLine($"Successful: {stats.SuccessfulDrops}");
Console.WriteLine($"Cancelled: {stats.CancelledOperations}");
Console.WriteLine($"Errors: {stats.ErrorCount}");
Console.WriteLine($"Avg time: {stats.AverageOperationTime.TotalMilliseconds}ms");
// Мониторинг в реальном времени
private Stopwatch _operationTimer;
service.DragStarted += (s, e) =>
{
_operationTimer = Stopwatch.StartNew();
Console.WriteLine($"Drag started from {e.DragInfo.Source}");
};
service.DragCompleted += (s, e) =>
{
_operationTimer.Stop();
Console.WriteLine($"Drag completed in {_operationTimer.ElapsedMilliseconds}ms");
if (service.EnableAsyncOperations)
{
var stats = service.GetStats();
Console.WriteLine($"Success rate: {(double)stats.SuccessfulDrops / stats.TotalDragOperations:P}");
}
};
```
### Оптимизация производительности
```csharp
// 1. Настройка параметров
var service = new DragDropService(options =>
{
options.DragStartThreshold = 4.0; // Увеличить порог для предотвращения случайных перетаскиваний
options.AsyncOperationTimeout = 2000; // Уменьшить таймаут для отзывчивости
options.EnableAutoCleanup = true; // Автоочистка неиспользуемых целей
});
// 2. Группировка целей
_service.RegisterDropTarget(target1, bounds1, group: "toolbox");
_service.RegisterDropTarget(target2, bounds2, group: "toolbox");
// Быстрое удаление всех целей группы
_service.UnregisterDropTargetsInGroup("toolbox");
// 3. Приоритеты для оптимизации поиска
_service.RegisterDropTarget(importantTarget, bounds, priority: 100); // Высокий приоритет
_service.RegisterDropTarget(defaultTarget, bounds, priority: 0); // Низкий приоритет
// 4. Периодическая очистка
service.ClearAllDropTargets(); // При смене контекста
```
## 🚀 Продвинутые сценарии
### Переупорядочивание элементов
```csharp
public class ReorderableListDropTarget : IAsyncDropTarget
{
private readonly IList<object> _items;
public async Task<bool> CanAcceptDropAsync(DropInfo dropInfo)
{
return dropInfo.Data is object && _items.Contains(dropInfo.Data);
}
public async Task DropAsync(DropInfo dropInfo)
{
var item = dropInfo.Data;
var insertIndex = CalculateInsertIndex(dropInfo);
// Удаляем из старой позиции
_items.Remove(item);
// Вставляем в новую позицию
if (insertIndex < _items.Count)
_items.Insert(insertIndex, item);
else
_items.Add(item);
dropInfo.MarkAsHandled();
}
private int CalculateInsertIndex(DropInfo dropInfo)
{
// Логика определения позиции вставки на основе dropInfo.Position
// и визуального расположения элементов
return 0;
}
}
```
### Мультиселект и групповое перетаскивание
```csharp
public class MultiSelectionDragSource : IAsyncDragSource
{
private readonly IEnumerable<object> _selectedItems;
public async Task<(bool CanStart, DragInfo? DragInfo)> CanStartDragAsync()
{
if (!_selectedItems.Any()) return (false, null);
// Создаем коллекцию для перетаскивания
var dragData = new DragItemCollection(_selectedItems);
var dragInfo = new DragInfo(
dragData,
DragDropEffects.Copy | DragDropEffects.Move,
Point.Zero,
this
);
dragInfo.SetParameter("ItemCount", _selectedItems.Count());
dragInfo.SetParameter("IsMultiSelect", true);
return (true, dragInfo);
}
}
```
## 📚 API Reference
### Основные интерфейсы
#### IDragDropService
```csharp
bool IsDragActive { get; }
DragInfo? CurrentDragInfo { get; }
IDropTarget? CurrentDropTarget { get; }
double DragStartThreshold { get; set; }
bool EnableAsyncOperations { get; set; }
// Регистрация целей
string RegisterDropTarget(IDropTarget target, Rect bounds, int priority = 0, string? group = null);
bool UpdateDropTargetBounds(string id, Rect bounds);
bool UnregisterDropTarget(string id);
void UnregisterDropTargetsInGroup(string group);
// Асинхронные операции
Task<bool> StartDragAsync(IDragSource source, Point startPosition);
Task UpdateDragAsync(Point position);
Task<DragDropEffects> EndDragAsync(Point position);
Task CancelDragAsync();
// Синхронные операции
bool StartDrag(IDragSource source, Point startPosition);
void UpdateDrag(Point position);
DragDropEffects EndDrag(Point position);
void CancelDrag();
// Утилиты
void ClearAllDropTargets();
DragDropStats GetStats();
```
#### IAsyncDragSource
```csharp
Task<(bool CanStart, DragInfo? DragInfo)> CanStartDragAsync();
Task<bool> StartDragAsync(DragInfo dragInfo);
Task DragCompletedAsync(DragInfo dragInfo, DragDropEffects effects);
Task DragCancelledAsync(DragInfo dragInfo);
```
#### IAsyncDropTarget
```csharp
Task<bool> CanAcceptDropAsync(DropInfo dropInfo);
Task DragOverAsync(DropInfo dropInfo);
Task DropAsync(DropInfo dropInfo);
Task DragLeaveAsync();
```
### Перечисления
#### DragDropEffects
```csharp
[Flags]
None = 0
Copy = 1 << 0 // Копирование данных
Move = 1 << 1 // Перемещение данных
Link = 1 << 2 // Ссылка на данные
CopyOrMove = Copy | Move
All = Copy | Move | Link
// Методы расширения:
bool CanCopy(this DragDropEffects effects)
bool CanMove(this DragDropEffects effects)
bool CanLink(this DragDropEffects effects)
DragDropEffects GetEffectFromKeys(bool controlKey, bool shiftKey, bool altKey)
```
#### DropPosition
```csharp
Inside // Внутри элемента
Top // Сверху
Bottom // Снизу
Left // Слева
Right // Справа
Center // По центру
```
## 🔮 Планы развития
1. **Интеграция с популярными UI фреймворками** (WinUI, Uno Platform, Avalonia)
2. **Поддержка жестов** (тач, мультитач)
3. **Виртуализация** для работы с большими наборами данных
4. **Продвинутые визуальные эффекты** (анимации, превью)
5. **Source Generators** для автоматической генерации кода
6. **Инструменты разработчика** (дебаггер, профилировщик)

View File

@@ -0,0 +1,829 @@
namespace Lattice.Core.DragDrop.Services;
/// <summary>
/// Реализация сервиса управления операциями перетаскивания.
/// Полностью потокобезопасная реализация с поддержкой async/await.
/// </summary>
public sealed class DragDropService : IDragDropService
{
#region Nested Types
private sealed class DropTargetInfo : IDisposable
{
public required Abstractions.IDropTarget Target { get; init; }
public required Geometry.Rect Bounds { get; set; }
public required int Priority { get; init; }
public required string? Group { get; init; }
public required string Id { get; init; }
public DateTime LastAccessTime { get; set; } = DateTime.UtcNow;
public int UsageCount { get; set; }
public void Dispose()
{
if (Target is IDisposable disposable)
disposable.Dispose();
}
}
private sealed class DragOperationContext : IDisposable
{
public Abstractions.IDragSource? Source { get; set; }
public Models.DragInfo? DragInfo { get; set; }
public Abstractions.IDropTarget? CurrentDropTarget { get; set; }
public CancellationTokenSource? CancellationTokenSource { get; set; }
public DateTime StartTime { get; set; } = DateTime.UtcNow;
public bool ThresholdExceeded { get; set; }
public void Dispose()
{
DragInfo?.Dispose();
CancellationTokenSource?.Dispose();
}
}
#endregion
#region Fields
private readonly Dictionary<string, DropTargetInfo> _dropTargets = new();
private readonly ReaderWriterLockSlim _dropTargetsLock = new(LockRecursionPolicy.NoRecursion);
private readonly object _dragOperationLock = new();
private DragOperationContext? _currentDragOperation;
private Timer? _cleanupTimer;
private bool _disposed;
private int _totalDragOperations;
private int _successfulDrops;
private int _cancelledOperations;
private int _errorCount;
private long _totalOperationTicks;
#endregion
#region Events
private event EventHandler<DragStartedEventArgs>? _dragStarted;
private event EventHandler<DragUpdatedEventArgs>? _dragUpdated;
private event EventHandler<DropTargetChangedEventArgs>? _dropTargetChanged;
private event EventHandler<DragCompletedEventArgs>? _dragCompleted;
private event EventHandler<DragCancelledEventArgs>? _dragCancelled;
private event EventHandler<DragDropErrorEventArgs>? _errorOccurred;
#endregion
#region Properties
public bool IsDragActive => Volatile.Read(ref _currentDragOperation) != null;
public Models.DragInfo? CurrentDragInfo => _currentDragOperation?.DragInfo;
public Abstractions.IDropTarget? CurrentDropTarget => _currentDragOperation?.CurrentDropTarget;
public double DragStartThreshold { get; set; } = 3.0;
public bool EnableAsyncOperations { get; set; } = true;
public int AsyncOperationTimeout { get; set; } = 5000;
public event EventHandler<DragStartedEventArgs> DragStarted
{
add => _dragStarted += value;
remove => _dragStarted -= value;
}
public event EventHandler<DragUpdatedEventArgs> DragUpdated
{
add => _dragUpdated += value;
remove => _dragUpdated -= value;
}
public event EventHandler<DropTargetChangedEventArgs> DropTargetChanged
{
add => _dropTargetChanged += value;
remove => _dropTargetChanged -= value;
}
public event EventHandler<DragCompletedEventArgs> DragCompleted
{
add => _dragCompleted += value;
remove => _dragCompleted -= value;
}
public event EventHandler<DragCancelledEventArgs> DragCancelled
{
add => _dragCancelled += value;
remove => _dragCancelled -= value;
}
public event EventHandler<DragDropErrorEventArgs> ErrorOccurred
{
add => _errorOccurred += value;
remove => _errorOccurred -= value;
}
#endregion
#region Constructor
public DragDropService()
{
// Инициализация таймера очистки (каждые 5 минут)
_cleanupTimer = new Timer(CleanupExpiredTargets, null,
TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
}
#endregion
#region Registration Methods
public string RegisterDropTarget(Abstractions.IDropTarget target, Geometry.Rect bounds, int priority = 0, string? group = null)
{
ThrowIfDisposed();
if (target == null) throw new ArgumentNullException(nameof(target));
var id = Guid.NewGuid().ToString("N");
var info = new DropTargetInfo
{
Target = target,
Bounds = bounds,
Priority = priority,
Group = group,
Id = id
};
_dropTargetsLock.EnterWriteLock();
try
{
_dropTargets[id] = info;
return id;
}
finally
{
_dropTargetsLock.ExitWriteLock();
}
}
public bool UpdateDropTargetBounds(string id, Geometry.Rect bounds)
{
ThrowIfDisposed();
_dropTargetsLock.EnterUpgradeableReadLock();
try
{
if (!_dropTargets.TryGetValue(id, out var info))
return false;
_dropTargetsLock.EnterWriteLock();
try
{
info.Bounds = bounds;
info.LastAccessTime = DateTime.UtcNow;
return true;
}
finally
{
_dropTargetsLock.ExitWriteLock();
}
}
finally
{
_dropTargetsLock.ExitUpgradeableReadLock();
}
}
public bool UnregisterDropTarget(string id)
{
ThrowIfDisposed();
_dropTargetsLock.EnterWriteLock();
try
{
if (_dropTargets.Remove(id, out var info))
{
info.Dispose();
return true;
}
return false;
}
finally
{
_dropTargetsLock.ExitWriteLock();
}
}
public void UnregisterDropTargetsInGroup(string group)
{
ThrowIfDisposed();
if (string.IsNullOrEmpty(group)) return;
_dropTargetsLock.EnterWriteLock();
try
{
var idsToRemove = new List<string>();
foreach (var kvp in _dropTargets)
{
if (kvp.Value.Group == group)
{
idsToRemove.Add(kvp.Key);
}
}
foreach (var id in idsToRemove)
{
if (_dropTargets.Remove(id, out var info))
{
info.Dispose();
}
}
}
finally
{
_dropTargetsLock.ExitWriteLock();
}
}
#endregion
#region Async Operations
public async Task<bool> StartDragAsync(Abstractions.IDragSource source, Geometry.Point startPosition)
{
ThrowIfDisposed();
if (source == null) throw new ArgumentNullException(nameof(source));
lock (_dragOperationLock)
{
if (_currentDragOperation != null)
return false;
}
try
{
Interlocked.Increment(ref _totalDragOperations);
Models.DragInfo? dragInfo = null;
// Проверка возможности начала перетаскивания
if (EnableAsyncOperations && source is Abstractions.IAsyncDragSource asyncSource)
{
var result = await ExecuteWithTimeoutAsync(
asyncSource.CanStartDragAsync(),
"CanStartDragAsync",
source);
if (!result.CanStart || result.DragInfo == null)
return false;
dragInfo = result.DragInfo;
}
else
{
if (!source.CanStartDrag(out dragInfo) || dragInfo == null)
return false;
}
var updatedDragInfo = dragInfo.CloneWithPosition(startPosition);
// Начало перетаскивания
bool started;
if (EnableAsyncOperations && source is Abstractions.IAsyncDragSource asyncSource2)
{
started = await ExecuteWithTimeoutAsync(
asyncSource2.StartDragAsync(updatedDragInfo),
"StartDragAsync",
source);
}
else
{
started = source.StartDrag(updatedDragInfo);
}
if (!started)
{
updatedDragInfo.Dispose();
return false;
}
lock (_dragOperationLock)
{
_currentDragOperation = new DragOperationContext
{
Source = source,
DragInfo = updatedDragInfo,
CancellationTokenSource = new CancellationTokenSource()
};
}
// Вызов события
_dragStarted?.Invoke(this, new DragStartedEventArgs(updatedDragInfo, startPosition));
return true;
}
catch (Exception ex)
{
Interlocked.Increment(ref _errorCount);
HandleError(ex, "StartDragAsync", source);
return false;
}
}
public async Task UpdateDragAsync(Geometry.Point position)
{
ThrowIfDisposed();
DragOperationContext? context;
lock (_dragOperationLock)
{
context = _currentDragOperation;
}
if (context?.DragInfo == null || context.Source == null)
return;
try
{
// Проверка порога начала
if (!context.ThresholdExceeded && DragStartThreshold > 0)
{
var distance = CalculateDistance(context.DragInfo.StartPosition, position);
if (distance < DragStartThreshold)
return;
context.ThresholdExceeded = true;
}
var updatedDragInfo = context.DragInfo.CloneWithPosition(position);
context.DragInfo.Dispose();
context.DragInfo = updatedDragInfo;
// Поиск новой цели сброса
var newDropTarget = await FindDropTargetAsync(position, updatedDragInfo);
// Обработка смены цели
if (context.CurrentDropTarget != newDropTarget?.Target)
{
if (context.CurrentDropTarget != null)
{
await ExecuteTargetOperationAsync(
context.CurrentDropTarget,
t => t.DragLeaveAsync(),
t => t.DragLeave(),
"DragLeave");
}
context.CurrentDropTarget = newDropTarget?.Target;
if (newDropTarget != null)
{
newDropTarget.UsageCount++;
_dropTargetChanged?.Invoke(this, new DropTargetChangedEventArgs(
updatedDragInfo, newDropTarget.Target, newDropTarget.Bounds));
}
}
// Уведомление текущей цели
if (context.CurrentDropTarget != null)
{
var dropInfo = new Models.DropInfo(
updatedDragInfo.Data,
position,
updatedDragInfo.AllowedEffects,
context.CurrentDropTarget);
await ExecuteTargetOperationAsync(
context.CurrentDropTarget,
t => t.DragOverAsync(dropInfo),
t => t.DragOver(dropInfo),
"DragOver");
}
_dragUpdated?.Invoke(this, new DragUpdatedEventArgs(updatedDragInfo, position));
}
catch (Exception ex)
{
Interlocked.Increment(ref _errorCount);
HandleError(ex, "UpdateDragAsync", context);
}
}
public async Task<Enums.DragDropEffects> EndDragAsync(Geometry.Point position)
{
ThrowIfDisposed();
DragOperationContext? context;
lock (_dragOperationLock)
{
context = _currentDragOperation;
_currentDragOperation = null;
}
if (context == null || context.DragInfo == null || context.Source == null)
{
Reset();
return Enums.DragDropEffects.None;
}
try
{
var effects = Enums.DragDropEffects.None;
var operationTime = DateTime.UtcNow - context.StartTime;
Interlocked.Add(ref _totalOperationTicks, operationTime.Ticks);
// Выполнение сброса
if (context.CurrentDropTarget != null)
{
var dropInfo = new Models.DropInfo(
context.DragInfo.Data,
position,
context.DragInfo.AllowedEffects,
context.CurrentDropTarget);
await ExecuteTargetOperationAsync(
context.CurrentDropTarget,
t => t.DropAsync(dropInfo),
t => t.Drop(dropInfo),
"Drop");
if (dropInfo.Handled)
{
effects = dropInfo.SuggestedEffects;
Interlocked.Increment(ref _successfulDrops);
}
}
// Уведомление источника
await ExecuteSourceOperationAsync(
context.Source,
s => s.DragCompletedAsync(context.DragInfo, effects),
s => s.DragCompleted(context.DragInfo, effects),
"DragCompleted",
effects);
// Событие завершения
_dragCompleted?.Invoke(this, new DragCompletedEventArgs(
context.DragInfo, position, effects));
return effects;
}
catch (Exception ex)
{
Interlocked.Increment(ref _errorCount);
HandleError(ex, "EndDragAsync", context);
return Enums.DragDropEffects.None;
}
finally
{
context.Dispose();
}
}
public async Task CancelDragAsync()
{
ThrowIfDisposed();
DragOperationContext? context;
lock (_dragOperationLock)
{
context = _currentDragOperation;
_currentDragOperation = null;
}
if (context == null || context.DragInfo == null || context.Source == null)
{
Reset();
return;
}
try
{
context.CancellationTokenSource?.Cancel();
Interlocked.Increment(ref _cancelledOperations);
await ExecuteSourceOperationAsync(
context.Source,
s => s.DragCancelledAsync(context.DragInfo),
s => s.DragCancelled(context.DragInfo),
"DragCancelled");
_dragCancelled?.Invoke(this, new DragCancelledEventArgs(context.DragInfo));
}
catch (Exception ex)
{
Interlocked.Increment(ref _errorCount);
HandleError(ex, "CancelDragAsync", context);
}
finally
{
context.Dispose();
}
}
#endregion
#region Synchronous Operations (for compatibility)
public bool StartDrag(Abstractions.IDragSource source, Geometry.Point startPosition)
{
return Task.Run(() => StartDragAsync(source, startPosition)).GetAwaiter().GetResult();
}
public void UpdateDrag(Geometry.Point position)
{
Task.Run(() => UpdateDragAsync(position)).GetAwaiter().GetResult();
}
public Enums.DragDropEffects EndDrag(Geometry.Point position)
{
return Task.Run(() => EndDragAsync(position)).GetAwaiter().GetResult();
}
public void CancelDrag()
{
Task.Run(CancelDragAsync).GetAwaiter().GetResult();
}
#endregion
#region Utility Methods
public void ClearAllDropTargets()
{
ThrowIfDisposed();
_dropTargetsLock.EnterWriteLock();
try
{
foreach (var info in _dropTargets.Values)
{
info.Dispose();
}
_dropTargets.Clear();
}
finally
{
_dropTargetsLock.ExitWriteLock();
}
}
public DragDropStats GetStats()
{
return new DragDropStats
{
TotalDragOperations = _totalDragOperations,
SuccessfulDrops = _successfulDrops,
CancelledOperations = _cancelledOperations,
ErrorCount = _errorCount,
RegisteredTargets = _dropTargets.Count,
AverageOperationTime = _totalDragOperations > 0
? TimeSpan.FromTicks(_totalOperationTicks / _totalDragOperations)
: TimeSpan.Zero
};
}
#endregion
#region Private Helper Methods
private async Task<DropTargetInfo?> FindDropTargetAsync(Geometry.Point position, Models.DragInfo dragInfo)
{
DropTargetInfo? bestTarget = null;
_dropTargetsLock.EnterReadLock();
try
{
foreach (var info in _dropTargets.Values)
{
if (!info.Bounds.Contains(position))
continue;
var dropInfo = new Models.DropInfo(
dragInfo.Data,
position,
dragInfo.AllowedEffects,
info.Target);
bool canAccept;
if (EnableAsyncOperations && info.Target is Abstractions.IAsyncDropTarget asyncTarget)
{
canAccept = await ExecuteWithTimeoutAsync(
asyncTarget.CanAcceptDropAsync(dropInfo),
"CanAcceptDropAsync",
info.Target);
}
else
{
canAccept = info.Target.CanAcceptDrop(dropInfo);
}
if (canAccept)
{
info.LastAccessTime = DateTime.UtcNow;
if (bestTarget == null || info.Priority > bestTarget.Priority)
{
bestTarget = info;
}
}
}
}
finally
{
_dropTargetsLock.ExitReadLock();
}
return bestTarget;
}
private async Task ExecuteTargetOperationAsync(
Abstractions.IDropTarget target,
Func<Abstractions.IAsyncDropTarget, Task> asyncOperation,
Action<Abstractions.IDropTarget> syncOperation,
string operationName)
{
try
{
if (EnableAsyncOperations && target is Abstractions.IAsyncDropTarget asyncTarget)
{
await ExecuteWithTimeoutAsync(
asyncOperation(asyncTarget),
$"{operationName}Async",
target);
}
else
{
syncOperation(target);
}
}
catch (Exception ex)
{
Interlocked.Increment(ref _errorCount);
HandleError(ex, operationName, target);
}
}
private async Task ExecuteSourceOperationAsync(
Abstractions.IDragSource source,
Func<Abstractions.IAsyncDragSource, Task> asyncOperation,
Action<Abstractions.IDragSource> syncOperation,
string operationName,
Enums.DragDropEffects effects = Enums.DragDropEffects.None)
{
try
{
if (EnableAsyncOperations && source is Abstractions.IAsyncDragSource asyncSource)
{
await ExecuteWithTimeoutAsync(
asyncOperation(asyncSource),
$"{operationName}Async",
source);
}
else
{
syncOperation(source);
}
}
catch (Exception ex)
{
Interlocked.Increment(ref _errorCount);
HandleError(ex, operationName, source);
}
}
private async Task<T> ExecuteWithTimeoutAsync<T>(Task<T> task, string operationName, object? context = null)
{
if (AsyncOperationTimeout <= 0)
return await task;
var timeoutTask = Task.Delay(AsyncOperationTimeout);
var completedTask = await Task.WhenAny(task, timeoutTask);
if (completedTask == timeoutTask)
{
throw new TimeoutException($"{operationName} timed out after {AsyncOperationTimeout}ms");
}
return await task;
}
private async Task ExecuteWithTimeoutAsync(Task task, string operationName, object? context = null)
{
if (AsyncOperationTimeout <= 0)
{
await task;
return;
}
var timeoutTask = Task.Delay(AsyncOperationTimeout);
var completedTask = await Task.WhenAny(task, timeoutTask);
if (completedTask == timeoutTask)
{
throw new TimeoutException($"{operationName} timed out after {AsyncOperationTimeout}ms");
}
await task;
}
private double CalculateDistance(Geometry.Point p1, Geometry.Point p2)
{
var dx = p2.X - p1.X;
var dy = p2.Y - p1.Y;
return Math.Sqrt(dx * dx + dy * dy);
}
private void Reset()
{
lock (_dragOperationLock)
{
_currentDragOperation?.Dispose();
_currentDragOperation = null;
}
}
private void CleanupExpiredTargets(object? state)
{
var expirationTime = DateTime.UtcNow.AddMinutes(-10); // Цели старше 10 минут
_dropTargetsLock.EnterWriteLock();
try
{
var idsToRemove = new List<string>();
foreach (var kvp in _dropTargets)
{
if (kvp.Value.LastAccessTime < expirationTime)
{
idsToRemove.Add(kvp.Key);
}
}
foreach (var id in idsToRemove)
{
if (_dropTargets.Remove(id, out var info))
{
info.Dispose();
}
}
}
finally
{
_dropTargetsLock.ExitWriteLock();
}
}
private void HandleError(Exception exception, string operation, object? context = null)
{
_errorOccurred?.Invoke(this, new DragDropErrorEventArgs(exception, operation, context));
}
private void ThrowIfDisposed()
{
if (_disposed)
throw new ObjectDisposedException(GetType().Name);
}
#endregion
#region IDisposable Implementation
public void Dispose()
{
if (_disposed) return;
lock (_dragOperationLock)
{
if (_disposed) return;
_cleanupTimer?.Dispose();
_cleanupTimer = null;
if (_currentDragOperation != null)
{
_currentDragOperation.CancellationTokenSource?.Cancel();
_currentDragOperation.Dispose();
_currentDragOperation = null;
}
ClearAllDropTargets();
_dropTargetsLock.Dispose();
// Очистка событий
_dragStarted = null;
_dragUpdated = null;
_dropTargetChanged = null;
_dragCompleted = null;
_dragCancelled = null;
_errorOccurred = null;
_disposed = true;
}
GC.SuppressFinalize(this);
}
#endregion
}

View File

@@ -0,0 +1,22 @@
using Lattice.Core.DragDrop.Models;
namespace Lattice.Core.DragDrop.Services;
/// <summary>
/// Аргументы события отмены перетаскивания.
/// </summary>
public class DragCancelledEventArgs : EventArgs
{
/// <summary>
/// Информация о перетаскивании.
/// </summary>
public DragInfo DragInfo { get; }
/// <summary>
/// Инициализирует новый экземпляр класса <see cref="DragCancelledEventArgs"/>.
/// </summary>
public DragCancelledEventArgs(DragInfo dragInfo)
{
DragInfo = dragInfo;
}
}

View File

@@ -0,0 +1,35 @@
using Lattice.Core.DragDrop.Models;
using Lattice.Core.Geometry;
namespace Lattice.Core.DragDrop.Services;
/// <summary>
/// Аргументы события завершения перетаскивания.
/// </summary>
public class DragCompletedEventArgs : EventArgs
{
/// <summary>
/// Информация о перетаскивании.
/// </summary>
public DragInfo DragInfo { get; }
/// <summary>
/// Позиция завершения перетаскивания.
/// </summary>
public Point DropPosition { get; }
/// <summary>
/// Примененные эффекты перетаскивания.
/// </summary>
public Enums.DragDropEffects Effects { get; }
/// <summary>
/// Инициализирует новый экземпляр класса <see cref="DragCompletedEventArgs"/>.
/// </summary>
public DragCompletedEventArgs(DragInfo dragInfo, Point dropPosition, Enums.DragDropEffects effects)
{
DragInfo = dragInfo;
DropPosition = dropPosition;
Effects = effects;
}
}

View File

@@ -0,0 +1,32 @@
namespace Lattice.Core.DragDrop.Services;
/// <summary>
/// Аргументы события ошибки в операции перетаскивания.
/// </summary>
public class DragDropErrorEventArgs : EventArgs
{
/// <summary>
/// Ошибка, которая произошла.
/// </summary>
public Exception Exception { get; }
/// <summary>
/// Операция, во время которой произошла ошибка.
/// </summary>
public string Operation { get; }
/// <summary>
/// Контекст операции.
/// </summary>
public object? Context { get; }
/// <summary>
/// Инициализирует новый экземпляр класса <see cref="DragDropErrorEventArgs"/>.
/// </summary>
public DragDropErrorEventArgs(Exception exception, string operation, object? context = null)
{
Exception = exception;
Operation = operation;
Context = context;
}
}

View File

@@ -0,0 +1,29 @@
using Lattice.Core.DragDrop.Models;
using Lattice.Core.Geometry;
namespace Lattice.Core.DragDrop.Services;
/// <summary>
/// Аргументы события начала перетаскивания.
/// </summary>
public class DragStartedEventArgs : EventArgs
{
/// <summary>
/// Информация о перетаскивании.
/// </summary>
public DragInfo DragInfo { get; }
/// <summary>
/// Начальная позиция перетаскивания.
/// </summary>
public Point StartPosition { get; }
/// <summary>
/// Инициализирует новый экземпляр класса <see cref="DragStartedEventArgs"/>.
/// </summary>
public DragStartedEventArgs(DragInfo dragInfo, Point startPosition)
{
DragInfo = dragInfo;
StartPosition = startPosition;
}
}

View File

@@ -0,0 +1,29 @@
using Lattice.Core.DragDrop.Models;
using Lattice.Core.Geometry;
namespace Lattice.Core.DragDrop.Services;
/// <summary>
/// Аргументы события обновления перетаскивания.
/// </summary>
public class DragUpdatedEventArgs : EventArgs
{
/// <summary>
/// Информация о перетаскивании.
/// </summary>
public DragInfo DragInfo { get; }
/// <summary>
/// Текущая позиция перетаскивания.
/// </summary>
public Point Position { get; }
/// <summary>
/// Инициализирует новый экземпляр класса <see cref="DragUpdatedEventArgs"/>.
/// </summary>
public DragUpdatedEventArgs(DragInfo dragInfo, Point position)
{
DragInfo = dragInfo;
Position = position;
}
}

View File

@@ -0,0 +1,35 @@
using Lattice.Core.DragDrop.Models;
using Lattice.Core.Geometry;
namespace Lattice.Core.DragDrop.Services;
/// <summary>
/// Аргументы события изменения цели сброса.
/// </summary>
public class DropTargetChangedEventArgs : EventArgs
{
/// <summary>
/// Информация о перетаскивании.
/// </summary>
public DragInfo DragInfo { get; }
/// <summary>
/// Новая цель сброса.
/// </summary>
public Abstractions.IDropTarget Target { get; }
/// <summary>
/// Границы цели.
/// </summary>
public Rect TargetBounds { get; }
/// <summary>
/// Инициализирует новый экземпляр класса <see cref="DropTargetChangedEventArgs"/>.
/// </summary>
public DropTargetChangedEventArgs(DragInfo dragInfo, Abstractions.IDropTarget target, Rect targetBounds)
{
DragInfo = dragInfo;
Target = target;
TargetBounds = targetBounds;
}
}

View File

@@ -0,0 +1,174 @@
namespace Lattice.Core.DragDrop.Services;
/// <summary>
/// Предоставляет централизованный сервис для управления операциями перетаскивания.
/// </summary>
public interface IDragDropService : IDisposable
{
#region Свойства
/// <summary>
/// Активна ли операция перетаскивания.
/// </summary>
bool IsDragActive { get; }
/// <summary>
/// Информация о текущей операции.
/// </summary>
Models.DragInfo? CurrentDragInfo { get; }
/// <summary>
/// Текущая цель сброса.
/// </summary>
Abstractions.IDropTarget? CurrentDropTarget { get; }
/// <summary>
/// Порог начала перетаскивания в пикселях.
/// </summary>
double DragStartThreshold { get; set; }
/// <summary>
/// Включены ли асинхронные операции.
/// </summary>
bool EnableAsyncOperations { get; set; }
/// <summary>
/// Максимальное время ожидания асинхронной операции (мс).
/// </summary>
int AsyncOperationTimeout { get; set; }
#endregion
#region События
/// <summary>
/// Событие начала операции перетаскивания.
/// </summary>
event EventHandler<DragStartedEventArgs> DragStarted;
/// <summary>
/// Событие обновления позиции перетаскивания.
/// </summary>
event EventHandler<DragUpdatedEventArgs> DragUpdated;
/// <summary>
/// Событие изменения цели сброса.
/// </summary>
event EventHandler<DropTargetChangedEventArgs> DropTargetChanged;
/// <summary>
/// Событие завершения операции перетаскивания.
/// </summary>
event EventHandler<DragCompletedEventArgs> DragCompleted;
/// <summary>
/// Событие отмены операции перетаскивания.
/// </summary>
event EventHandler<DragCancelledEventArgs> DragCancelled;
/// <summary>
/// Событие ошибки в операции перетаскивания.
/// </summary>
event EventHandler<DragDropErrorEventArgs> ErrorOccurred;
#endregion
#region Регистрация целей сброса
/// <summary>
/// Регистрирует цель сброса.
/// </summary>
string RegisterDropTarget(Abstractions.IDropTarget target, Geometry.Rect bounds, int priority = 0, string? group = null);
/// <summary>
/// Обновляет границы цели сброса.
/// </summary>
bool UpdateDropTargetBounds(string id, Geometry.Rect bounds);
/// <summary>
/// Отменяет регистрацию цели сброса.
/// </summary>
bool UnregisterDropTarget(string id);
/// <summary>
/// Отменяет регистрацию всех целей в группе.
/// </summary>
void UnregisterDropTargetsInGroup(string group);
#endregion
#region Асинхронные операции
/// <summary>
/// Начинает операцию перетаскивания (асинхронно).
/// </summary>
Task<bool> StartDragAsync(Abstractions.IDragSource source, Geometry.Point startPosition);
/// <summary>
/// Обновляет позицию перетаскивания (асинхронно).
/// </summary>
Task UpdateDragAsync(Geometry.Point position);
/// <summary>
/// Завершает операцию перетаскивания (асинхронно).
/// </summary>
Task<Enums.DragDropEffects> EndDragAsync(Geometry.Point position);
/// <summary>
/// Отменяет операцию перетаскивания (асинхронно).
/// </summary>
Task CancelDragAsync();
#endregion
#region Синхронные операции (для обратной совместимости)
/// <summary>
/// Начинает операцию перетаскивания (синхронно).
/// </summary>
bool StartDrag(Abstractions.IDragSource source, Geometry.Point startPosition);
/// <summary>
/// Обновляет позицию перетаскивания (синхронно).
/// </summary>
void UpdateDrag(Geometry.Point position);
/// <summary>
/// Завершает операцию перетаскивания (синхронно).
/// </summary>
Enums.DragDropEffects EndDrag(Geometry.Point position);
/// <summary>
/// Отменяет операцию перетаскивания (синхронно).
/// </summary>
void CancelDrag();
#endregion
#region Утилиты
/// <summary>
/// Очищает все зарегистрированные цели.
/// </summary>
void ClearAllDropTargets();
/// <summary>
/// Получает статистику использования.
/// </summary>
DragDropStats GetStats();
#endregion
}
/// <summary>
/// Статистика использования Drag & Drop.
/// </summary>
public class DragDropStats
{
public int TotalDragOperations { get; set; }
public int SuccessfulDrops { get; set; }
public int CancelledOperations { get; set; }
public int ErrorCount { get; set; }
public int RegisteredTargets { get; set; }
public TimeSpan AverageOperationTime { get; set; }
}

View File

@@ -0,0 +1,713 @@
using Lattice.Core.DragDrop.Abstractions;
using Lattice.Core.DragDrop.Enums;
using Lattice.Core.DragDrop.Models;
namespace Lattice.Core.DragDrop.Utilities;
/// <summary>
/// Предоставляет утилитарные методы и фабричные методы для работы с системой перетаскивания с поддержкой async.
/// </summary>
public static class AsyncDragDropUtilities
{
/// <summary>
/// Создает асинхронную реализацию источника перетаскивания.
/// </summary>
public static IAsyncDragSource CreateAsyncDragSource(
Func<Task<object>> dataProviderAsync,
Func<Task<bool>>? canDragAsync = null,
Func<DragInfo, DragDropEffects, Task>? onCompletedAsync = null,
Func<DragInfo, Task>? onCancelledAsync = null)
{
return new AsyncDragSourceWrapper(dataProviderAsync, canDragAsync, onCompletedAsync, onCancelledAsync);
}
/// <summary>
/// Создает асинхронную реализацию цели сброса.
/// </summary>
public static IAsyncDropTarget CreateAsyncDropTarget(
Func<DropInfo, Task<bool>>? canAcceptAsync = null,
Func<DropInfo, Task>? onDragOverAsync = null,
Func<DropInfo, Task>? onDropAsync = null,
Func<Task>? onDragLeaveAsync = null)
{
return new AsyncDropTargetWrapper(canAcceptAsync, onDragOverAsync, onDropAsync, onDragLeaveAsync);
}
/// <summary>
/// Создает адаптер для преобразования синхронного источника в асинхронный.
/// </summary>
public static IAsyncDragSource CreateAsyncAdapter(IDragSource syncSource)
{
return new SyncToAsyncDragSourceAdapter(syncSource);
}
/// <summary>
/// Создает адаптер для преобразования синхронной цели в асинхронную.
/// </summary>
public static IAsyncDropTarget CreateAsyncAdapter(IDropTarget syncTarget)
{
return new SyncToAsyncDropTargetAdapter(syncTarget);
}
#region Обертки-реализации
/// <summary>
/// Обертка для создания асинхронного источника перетаскивания.
/// </summary>
private sealed class AsyncDragSourceWrapper : IAsyncDragSource
{
private readonly Func<Task<object>> _dataProviderAsync;
private readonly Func<Task<bool>>? _canDragAsync;
private readonly Func<DragInfo, DragDropEffects, Task>? _onCompletedAsync;
private readonly Func<DragInfo, Task>? _onCancelledAsync;
public AsyncDragSourceWrapper(
Func<Task<object>> dataProviderAsync,
Func<Task<bool>>? canDragAsync = null,
Func<DragInfo, DragDropEffects, Task>? onCompletedAsync = null,
Func<DragInfo, Task>? onCancelledAsync = null)
{
_dataProviderAsync = dataProviderAsync ?? throw new ArgumentNullException(nameof(dataProviderAsync));
_canDragAsync = canDragAsync;
_onCompletedAsync = onCompletedAsync;
_onCancelledAsync = onCancelledAsync;
}
public async Task<(bool CanStart, DragInfo? DragInfo)> CanStartDragAsync()
{
try
{
// Проверяем, может ли начаться перетаскивание
if (_canDragAsync != null)
{
var canDrag = await _canDragAsync().ConfigureAwait(false);
if (!canDrag)
return (false, null);
}
// Получаем данные
var data = await _dataProviderAsync().ConfigureAwait(false);
if (data == null)
return (false, null);
// Создаем информацию о перетаскивании
var dragInfo = DragDropUtilities.CreateDragInfo(
data,
Geometry.Point.Zero,
DragDropEffects.Copy | DragDropEffects.Move,
this);
return (true, dragInfo);
}
catch
{
return (false, null);
}
}
public Task<bool> StartDragAsync(DragInfo dragInfo)
{
// Базовая реализация всегда разрешает начало
return Task.FromResult(true);
}
public async Task DragCompletedAsync(DragInfo dragInfo, DragDropEffects effects)
{
if (_onCompletedAsync != null)
{
await _onCompletedAsync(dragInfo, effects).ConfigureAwait(false);
}
}
public async Task DragCancelledAsync(DragInfo dragInfo)
{
if (_onCancelledAsync != null)
{
await _onCancelledAsync(dragInfo).ConfigureAwait(false);
}
}
#region Синхронная реализация (для IDragSource)
public bool CanStartDrag(out DragInfo? dragInfo)
{
// Для синхронного вызова используем Task.Result
var result = Task.Run(() => CanStartDragAsync()).GetAwaiter().GetResult();
dragInfo = result.DragInfo;
return result.CanStart;
}
public bool StartDrag(DragInfo dragInfo)
{
return Task.Run(() => StartDragAsync(dragInfo)).GetAwaiter().GetResult();
}
public void DragCompleted(DragInfo dragInfo, DragDropEffects effects)
{
Task.Run(() => DragCompletedAsync(dragInfo, effects)).Wait();
}
public void DragCancelled(DragInfo dragInfo)
{
Task.Run(() => DragCancelledAsync(dragInfo)).Wait();
}
#endregion
}
/// <summary>
/// Обертка для создания асинхронной цели сброса.
/// </summary>
private sealed class AsyncDropTargetWrapper : IAsyncDropTarget
{
private readonly Func<DropInfo, Task<bool>>? _canAcceptAsync;
private readonly Func<DropInfo, Task>? _onDragOverAsync;
private readonly Func<DropInfo, Task>? _onDropAsync;
private readonly Func<Task>? _onDragLeaveAsync;
public AsyncDropTargetWrapper(
Func<DropInfo, Task<bool>>? canAcceptAsync = null,
Func<DropInfo, Task>? onDragOverAsync = null,
Func<DropInfo, Task>? onDropAsync = null,
Func<Task>? onDragLeaveAsync = null)
{
_canAcceptAsync = canAcceptAsync;
_onDragOverAsync = onDragOverAsync;
_onDropAsync = onDropAsync;
_onDragLeaveAsync = onDragLeaveAsync;
}
public async Task<bool> CanAcceptDropAsync(DropInfo dropInfo)
{
try
{
if (_canAcceptAsync != null)
{
return await _canAcceptAsync(dropInfo).ConfigureAwait(false);
}
return true; // По умолчанию принимаем все
}
catch
{
return false; // При ошибке не принимаем
}
}
public async Task DragOverAsync(DropInfo dropInfo)
{
try
{
if (_onDragOverAsync != null)
{
await _onDragOverAsync(dropInfo).ConfigureAwait(false);
}
}
catch
{
// Игнорируем ошибки в обработчике
}
}
public async Task DropAsync(DropInfo dropInfo)
{
try
{
if (_onDropAsync != null)
{
await _onDropAsync(dropInfo).ConfigureAwait(false);
}
}
catch
{
// Игнорируем ошибки в обработчике
}
}
public async Task DragLeaveAsync()
{
try
{
if (_onDragLeaveAsync != null)
{
await _onDragLeaveAsync().ConfigureAwait(false);
}
}
catch
{
// Игнорируем ошибки в обработчике
}
}
#region Синхронная реализация (для IDropTarget)
public bool CanAcceptDrop(DropInfo dropInfo)
{
return Task.Run(() => CanAcceptDropAsync(dropInfo)).GetAwaiter().GetResult();
}
public void DragOver(DropInfo dropInfo)
{
Task.Run(() => DragOverAsync(dropInfo)).Wait();
}
public void Drop(DropInfo dropInfo)
{
Task.Run(() => DropAsync(dropInfo)).Wait();
}
public void DragLeave()
{
Task.Run(DragLeaveAsync).Wait();
}
#endregion
}
/// <summary>
/// Адаптер для преобразования синхронного источника в асинхронный.
/// </summary>
private sealed class SyncToAsyncDragSourceAdapter : IAsyncDragSource
{
private readonly IDragSource _syncSource;
public SyncToAsyncDragSourceAdapter(IDragSource syncSource)
{
_syncSource = syncSource ?? throw new ArgumentNullException(nameof(syncSource));
}
public async Task<(bool CanStart, DragInfo? DragInfo)> CanStartDragAsync()
{
return await Task.Run(() =>
{
var canStart = _syncSource.CanStartDrag(out var dragInfo);
return (canStart, dragInfo);
}).ConfigureAwait(false);
}
public async Task<bool> StartDragAsync(DragInfo dragInfo)
{
return await Task.Run(() => _syncSource.StartDrag(dragInfo)).ConfigureAwait(false);
}
public async Task DragCompletedAsync(DragInfo dragInfo, DragDropEffects effects)
{
await Task.Run(() => _syncSource.DragCompleted(dragInfo, effects)).ConfigureAwait(false);
}
public async Task DragCancelledAsync(DragInfo dragInfo)
{
await Task.Run(() => _syncSource.DragCancelled(dragInfo)).ConfigureAwait(false);
}
#region Синхронная реализация (делегируем синхронному источнику)
public bool CanStartDrag(out DragInfo? dragInfo)
{
return _syncSource.CanStartDrag(out dragInfo);
}
public bool StartDrag(DragInfo dragInfo)
{
return _syncSource.StartDrag(dragInfo);
}
public void DragCompleted(DragInfo dragInfo, DragDropEffects effects)
{
_syncSource.DragCompleted(dragInfo, effects);
}
public void DragCancelled(DragInfo dragInfo)
{
_syncSource.DragCancelled(dragInfo);
}
#endregion
}
/// <summary>
/// Адаптер для преобразования синхронной цели в асинхронную.
/// </summary>
private sealed class SyncToAsyncDropTargetAdapter : IAsyncDropTarget
{
private readonly IDropTarget _syncTarget;
public SyncToAsyncDropTargetAdapter(IDropTarget syncTarget)
{
_syncTarget = syncTarget ?? throw new ArgumentNullException(nameof(syncTarget));
}
public async Task<bool> CanAcceptDropAsync(DropInfo dropInfo)
{
return await Task.Run(() => _syncTarget.CanAcceptDrop(dropInfo)).ConfigureAwait(false);
}
public async Task DragOverAsync(DropInfo dropInfo)
{
await Task.Run(() => _syncTarget.DragOver(dropInfo)).ConfigureAwait(false);
}
public async Task DropAsync(DropInfo dropInfo)
{
await Task.Run(() => _syncTarget.Drop(dropInfo)).ConfigureAwait(false);
}
public async Task DragLeaveAsync()
{
await Task.Run(() => _syncTarget.DragLeave()).ConfigureAwait(false);
}
#region Синхронная реализация (делегируем синхронной цели)
public bool CanAcceptDrop(DropInfo dropInfo)
{
return _syncTarget.CanAcceptDrop(dropInfo);
}
public void DragOver(DropInfo dropInfo)
{
_syncTarget.DragOver(dropInfo);
}
public void Drop(DropInfo dropInfo)
{
_syncTarget.Drop(dropInfo);
}
public void DragLeave()
{
_syncTarget.DragLeave();
}
#endregion
}
#endregion
#region Утилитарные методы
/// <summary>
/// Выполняет асинхронную операцию с таймаутом.
/// </summary>
public static async Task<T> ExecuteWithTimeoutAsync<T>(
Task<T> task,
TimeSpan timeout,
T defaultValue = default!)
{
if (timeout <= TimeSpan.Zero)
return await task.ConfigureAwait(false);
var delayTask = Task.Delay(timeout);
var completedTask = await Task.WhenAny(task, delayTask).ConfigureAwait(false);
if (completedTask == delayTask)
{
return defaultValue;
}
return await task.ConfigureAwait(false);
}
/// <summary>
/// Выполняет асинхронную операцию с таймаутом.
/// </summary>
public static async Task<bool> ExecuteWithTimeoutAsync(
Task task,
TimeSpan timeout)
{
if (timeout <= TimeSpan.Zero)
{
await task.ConfigureAwait(false);
return true;
}
var delayTask = Task.Delay(timeout);
var completedTask = await Task.WhenAny(task, delayTask).ConfigureAwait(false);
return completedTask == task;
}
/// <summary>
/// Выполняет асинхронную операцию с таймаутом и обработкой ошибок.
/// </summary>
public static async Task<T?> ExecuteSafeWithTimeoutAsync<T>(
Task<T> task,
TimeSpan timeout,
Func<Exception, T?> errorHandler = null) where T : class
{
try
{
if (timeout <= TimeSpan.Zero)
return await task.ConfigureAwait(false);
var delayTask = Task.Delay(timeout);
var completedTask = await Task.WhenAny(task, delayTask).ConfigureAwait(false);
if (completedTask == delayTask)
{
return default;
}
return await task.ConfigureAwait(false);
}
catch (Exception ex)
{
return errorHandler?.Invoke(ex) ?? default;
}
}
/// <summary>
/// Создает комбинированный источник из синхронного и асинхронного.
/// </summary>
public static IAsyncDragSource Combine(
IDragSource syncSource,
IAsyncDragSource asyncSource,
bool preferAsync = true)
{
return new CombinedDragSource(syncSource, asyncSource, preferAsync);
}
/// <summary>
/// Создает комбинированную цель из синхронной и асинхронной.
/// </summary>
public static IAsyncDropTarget Combine(
IDropTarget syncTarget,
IAsyncDropTarget asyncTarget,
bool preferAsync = true)
{
return new CombinedDropTarget(syncTarget, asyncTarget, preferAsync);
}
#endregion
#region Комбинированные реализации
/// <summary>
/// Комбинированный источник, поддерживающий как синхронный, так и асинхронный API.
/// </summary>
private sealed class CombinedDragSource : IAsyncDragSource
{
private readonly IDragSource _syncSource;
private readonly IAsyncDragSource _asyncSource;
private readonly bool _preferAsync;
public CombinedDragSource(IDragSource syncSource, IAsyncDragSource asyncSource, bool preferAsync)
{
_syncSource = syncSource ?? throw new ArgumentNullException(nameof(syncSource));
_asyncSource = asyncSource ?? throw new ArgumentNullException(nameof(asyncSource));
_preferAsync = preferAsync;
}
public async Task<(bool CanStart, DragInfo? DragInfo)> CanStartDragAsync()
{
if (_preferAsync)
{
try
{
return await _asyncSource.CanStartDragAsync().ConfigureAwait(false);
}
catch
{
// В случае ошибки пробуем синхронную версию
}
}
// Используем синхронную версию в отдельной задаче
return await Task.Run(() =>
{
var canStart = _syncSource.CanStartDrag(out var dragInfo);
return (canStart, dragInfo);
}).ConfigureAwait(false);
}
public async Task<bool> StartDragAsync(DragInfo dragInfo)
{
if (_preferAsync)
{
try
{
return await _asyncSource.StartDragAsync(dragInfo).ConfigureAwait(false);
}
catch
{
// В случае ошибки пробуем синхронную версию
}
}
return await Task.Run(() => _syncSource.StartDrag(dragInfo)).ConfigureAwait(false);
}
public async Task DragCompletedAsync(DragInfo dragInfo, DragDropEffects effects)
{
if (_preferAsync)
{
try
{
await _asyncSource.DragCompletedAsync(dragInfo, effects).ConfigureAwait(false);
return;
}
catch
{
// В случае ошибки пробуем синхронную версию
}
}
await Task.Run(() => _syncSource.DragCompleted(dragInfo, effects)).ConfigureAwait(false);
}
public async Task DragCancelledAsync(DragInfo dragInfo)
{
if (_preferAsync)
{
try
{
await _asyncSource.DragCancelledAsync(dragInfo).ConfigureAwait(false);
return;
}
catch
{
// В случае ошибки пробуем синхронную версию
}
}
await Task.Run(() => _syncSource.DragCancelled(dragInfo)).ConfigureAwait(false);
}
#region Синхронная реализация
public bool CanStartDrag(out DragInfo? dragInfo)
{
return _syncSource.CanStartDrag(out dragInfo);
}
public bool StartDrag(DragInfo dragInfo)
{
return _syncSource.StartDrag(dragInfo);
}
public void DragCompleted(DragInfo dragInfo, DragDropEffects effects)
{
_syncSource.DragCompleted(dragInfo, effects);
}
public void DragCancelled(DragInfo dragInfo)
{
_syncSource.DragCancelled(dragInfo);
}
#endregion
}
/// <summary>
/// Комбинированная цель, поддерживающая как синхронный, так и асинхронный API.
/// </summary>
private sealed class CombinedDropTarget : IAsyncDropTarget
{
private readonly IDropTarget _syncTarget;
private readonly IAsyncDropTarget _asyncTarget;
private readonly bool _preferAsync;
public CombinedDropTarget(IDropTarget syncTarget, IAsyncDropTarget asyncTarget, bool preferAsync)
{
_syncTarget = syncTarget ?? throw new ArgumentNullException(nameof(syncTarget));
_asyncTarget = asyncTarget ?? throw new ArgumentNullException(nameof(asyncTarget));
_preferAsync = preferAsync;
}
public async Task<bool> CanAcceptDropAsync(DropInfo dropInfo)
{
if (_preferAsync)
{
try
{
return await _asyncTarget.CanAcceptDropAsync(dropInfo).ConfigureAwait(false);
}
catch
{
// В случае ошибки пробуем синхронную версию
}
}
return await Task.Run(() => _syncTarget.CanAcceptDrop(dropInfo)).ConfigureAwait(false);
}
public async Task DragOverAsync(DropInfo dropInfo)
{
if (_preferAsync)
{
try
{
await _asyncTarget.DragOverAsync(dropInfo).ConfigureAwait(false);
return;
}
catch
{
// В случае ошибки пробуем синхронную версию
}
}
await Task.Run(() => _syncTarget.DragOver(dropInfo)).ConfigureAwait(false);
}
public async Task DropAsync(DropInfo dropInfo)
{
if (_preferAsync)
{
try
{
await _asyncTarget.DropAsync(dropInfo).ConfigureAwait(false);
return;
}
catch
{
// В случае ошибки пробуем синхронную версию
}
}
await Task.Run(() => _syncTarget.Drop(dropInfo)).ConfigureAwait(false);
}
public async Task DragLeaveAsync()
{
if (_preferAsync)
{
try
{
await _asyncTarget.DragLeaveAsync().ConfigureAwait(false);
return;
}
catch
{
// В случае ошибки пробуем синхронную версию
}
}
await Task.Run(() => _syncTarget.DragLeave()).ConfigureAwait(false);
}
#region Синхронная реализация
public bool CanAcceptDrop(DropInfo dropInfo)
{
return _syncTarget.CanAcceptDrop(dropInfo);
}
public void DragOver(DropInfo dropInfo)
{
_syncTarget.DragOver(dropInfo);
}
public void Drop(DropInfo dropInfo)
{
_syncTarget.Drop(dropInfo);
}
public void DragLeave()
{
_syncTarget.DragLeave();
}
#endregion
}
#endregion
}

View File

@@ -0,0 +1,275 @@
namespace Lattice.Core.DragDrop.Utilities;
/// <summary>
/// Утилиты для работы с системой перетаскивания.
/// </summary>
public static class DragDropUtilities
{
#region Effect Utilities
/// <summary>
/// Проверяет, совместимы ли эффекты источника и цели.
/// </summary>
public static bool AreEffectsCompatible(Enums.DragDropEffects sourceEffects, Enums.DragDropEffects targetEffects)
{
if (sourceEffects == Enums.DragDropEffects.None || targetEffects == Enums.DragDropEffects.None)
return false;
return (sourceEffects & targetEffects) != Enums.DragDropEffects.None;
}
/// <summary>
/// Получает наиболее подходящий эффект на основе доступных.
/// </summary>
public static Enums.DragDropEffects GetBestEffect(Enums.DragDropEffects available, Enums.DragDropEffects preferred)
{
if ((available & preferred) != Enums.DragDropEffects.None)
return available & preferred;
if ((available & Enums.DragDropEffects.Move) != Enums.DragDropEffects.None)
return Enums.DragDropEffects.Move;
if ((available & Enums.DragDropEffects.Copy) != Enums.DragDropEffects.None)
return Enums.DragDropEffects.Copy;
if ((available & Enums.DragDropEffects.Link) != Enums.DragDropEffects.None)
return Enums.DragDropEffects.Link;
return Enums.DragDropEffects.None;
}
#endregion
#region Geometry Utilities
/// <summary>
/// Вычисляет расстояние между двумя точками.
/// </summary>
public static double CalculateDistance(Geometry.Point p1, Geometry.Point p2)
{
var dx = p2.X - p1.X;
var dy = p2.Y - p1.Y;
return Math.Sqrt(dx * dx + dy * dy);
}
/// <summary>
/// Проверяет, превысило ли перемещение пороговое значение.
/// </summary>
public static bool HasExceededDragThreshold(Geometry.Point startPoint, Geometry.Point currentPoint, double threshold)
{
var distance = CalculateDistance(startPoint, currentPoint);
return distance >= threshold;
}
/// <summary>
/// Определяет позицию сброса относительно прямоугольника.
/// </summary>
public static Enums.DropPosition GetDropPosition(Geometry.Point point, Geometry.Rect bounds, double edgeThreshold = 20.0)
{
if (!bounds.Contains(new Geometry.Point(point.X, point.Y)))
return Enums.DropPosition.Inside;
var relativeX = (point.X - bounds.X) / bounds.Width;
var relativeY = (point.Y - bounds.Y) / bounds.Height;
if (relativeX < edgeThreshold / bounds.Width)
return Enums.DropPosition.Left;
if (relativeX > 1 - edgeThreshold / bounds.Width)
return Enums.DropPosition.Right;
if (relativeY < edgeThreshold / bounds.Height)
return Enums.DropPosition.Top;
if (relativeY > 1 - edgeThreshold / bounds.Height)
return Enums.DropPosition.Bottom;
return Enums.DropPosition.Center;
}
#endregion
#region Factory Methods
/// <summary>
/// Создает информацию о перетаскивании.
/// </summary>
public static Models.DragInfo CreateDragInfo(
object data,
Geometry.Point startPosition,
Enums.DragDropEffects allowedEffects = Enums.DragDropEffects.Copy | Enums.DragDropEffects.Move,
object? source = null,
Dictionary<string, object>? parameters = null)
{
var dragInfo = new Models.DragInfo(data, allowedEffects, startPosition, source);
if (parameters != null)
{
foreach (var param in parameters)
{
dragInfo.SetParameter(param.Key, param.Value);
}
}
return dragInfo;
}
/// <summary>
/// Создает простую реализацию источника перетаскивания.
/// </summary>
public static Abstractions.IDragSource CreateSimpleDragSource(
Func<object> dataProvider,
Func<bool>? canDrag = null,
Action<Models.DragInfo, Enums.DragDropEffects>? onCompleted = null,
Action<Models.DragInfo>? onCancelled = null)
{
return new SimpleDragSource(dataProvider, canDrag, onCompleted, onCancelled);
}
/// <summary>
/// Создает простую реализацию цели сброса.
/// </summary>
public static Abstractions.IDropTarget CreateSimpleDropTarget(
Func<Models.DropInfo, bool>? canAccept = null,
Action<Models.DropInfo>? onDragOver = null,
Action<Models.DropInfo>? onDrop = null,
Action? onDragLeave = null)
{
return new SimpleDropTarget(canAccept, onDragOver, onDrop, onDragLeave);
}
#endregion
#region Data Extraction
/// <summary>
/// Извлекает данные из элемента с поддержкой различных паттернов.
/// </summary>
public static object? ExtractData(object? element)
{
if (element == null)
return null;
// Проверяем, реализует ли элемент специальный интерфейс
if (element is Abstractions.IDragSource dragSource)
{
if (dragSource.CanStartDrag(out var dragInfo) && dragInfo != null)
return dragInfo.Data;
}
// В реальной реализации здесь будет рефлексия для проверки свойств
// DataContext, Content и т.д.
// Возвращаем сам элемент как данные
return element;
}
/// <summary>
/// Проверяет, совместимы ли данные с указанными типами.
/// </summary>
public static bool IsDataCompatible(object? data, IEnumerable<Type>? acceptedTypes)
{
if (data == null || acceptedTypes == null)
return false;
var dataType = data.GetType();
foreach (var acceptedType in acceptedTypes)
{
if (acceptedType.IsAssignableFrom(dataType))
return true;
}
return false;
}
#endregion
#region Helper Classes
private sealed class SimpleDragSource : Abstractions.IDragSource
{
private readonly Func<object> _dataProvider;
private readonly Func<bool>? _canDrag;
private readonly Action<Models.DragInfo, Enums.DragDropEffects>? _onCompleted;
private readonly Action<Models.DragInfo>? _onCancelled;
public SimpleDragSource(
Func<object> dataProvider,
Func<bool>? canDrag = null,
Action<Models.DragInfo, Enums.DragDropEffects>? onCompleted = null,
Action<Models.DragInfo>? onCancelled = null)
{
_dataProvider = dataProvider ?? throw new ArgumentNullException(nameof(dataProvider));
_canDrag = canDrag;
_onCompleted = onCompleted;
_onCancelled = onCancelled;
}
public bool CanStartDrag(out Models.DragInfo? dragInfo)
{
dragInfo = null;
if (_canDrag?.Invoke() == false)
return false;
var data = _dataProvider();
if (data == null)
return false;
dragInfo = CreateDragInfo(data, Geometry.Point.Zero, Enums.DragDropEffects.Copy | Enums.DragDropEffects.Move, this);
return true;
}
public bool StartDrag(Models.DragInfo dragInfo) => true;
public void DragCompleted(Models.DragInfo dragInfo, Enums.DragDropEffects effects)
{
_onCompleted?.Invoke(dragInfo, effects);
}
public void DragCancelled(Models.DragInfo dragInfo)
{
_onCancelled?.Invoke(dragInfo);
}
}
private sealed class SimpleDropTarget : Abstractions.IDropTarget
{
private readonly Func<Models.DropInfo, bool>? _canAccept;
private readonly Action<Models.DropInfo>? _onDragOver;
private readonly Action<Models.DropInfo>? _onDrop;
private readonly Action? _onDragLeave;
public SimpleDropTarget(
Func<Models.DropInfo, bool>? canAccept = null,
Action<Models.DropInfo>? onDragOver = null,
Action<Models.DropInfo>? onDrop = null,
Action? onDragLeave = null)
{
_canAccept = canAccept;
_onDragOver = onDragOver;
_onDrop = onDrop;
_onDragLeave = onDragLeave;
}
public bool CanAcceptDrop(Models.DropInfo dropInfo)
{
return _canAccept?.Invoke(dropInfo) ?? true;
}
public void DragOver(Models.DropInfo dropInfo)
{
_onDragOver?.Invoke(dropInfo);
}
public void Drop(Models.DropInfo dropInfo)
{
_onDrop?.Invoke(dropInfo);
}
public void DragLeave()
{
_onDragLeave?.Invoke();
}
}
#endregion
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,79 @@
namespace Lattice.Core.Geometry;
/// <summary>
/// Представляет точку в двумерном пространстве с координатами X и Y.
/// Эта структура является платформонезависимой и может использоваться
/// во всех слоях системы Lattice.
/// </summary>
public struct Point : IEquatable<Point>
{
/// <summary>
/// Получает точку с координатами (0, 0).
/// </summary>
public static readonly Point Zero = new(0, 0);
/// <summary>
/// Координата X (горизонтальная).
/// </summary>
public double X { get; set; }
/// <summary>
/// Координата Y (вертикальная).
/// </summary>
public double Y { get; set; }
/// <summary>
/// Инициализирует новую точку с указанными координатами.
/// </summary>
/// <param name="x">Координата X.</param>
/// <param name="y">Координата Y.</param>
public Point(double x, double y)
{
X = x;
Y = y;
}
/// <summary>
/// Создает точку из System.Drawing.Point.
/// </summary>
public static Point FromDrawingPoint(System.Drawing.Point point) =>
new(point.X, point.Y);
/// <summary>
/// Преобразует точку в System.Drawing.Point.
/// </summary>
public System.Drawing.Point ToDrawingPoint() =>
new((int)X, (int)Y);
/// <summary>
/// Определяет, равна ли эта точка другой точке.
/// </summary>
public bool Equals(Point other) =>
Math.Abs(X - other.X) < double.Epsilon &&
Math.Abs(Y - other.Y) < double.Epsilon;
/// <inheritdoc/>
public override bool Equals(object? obj) =>
obj is Point point && Equals(point);
/// <inheritdoc/>
public override int GetHashCode() =>
HashCode.Combine(X, Y);
/// <summary>
/// Определяет, равны ли две точки.
/// </summary>
public static bool operator ==(Point left, Point right) =>
left.Equals(right);
/// <summary>
/// Определяет, не равны ли две точки.
/// </summary>
public static bool operator !=(Point left, Point right) =>
!left.Equals(right);
/// <summary>
/// Возвращает строковое представление точки.
/// </summary>
public override string ToString() => $"{X}, {Y}";
}

View File

@@ -0,0 +1,153 @@
namespace Lattice.Core.Geometry;
/// <summary>
/// Представляет прямоугольник в двумерном пространстве с позицией и размерами.
/// Эта структура является платформонезависимой и может использоваться
/// во всех слоях системы Lattice.
/// </summary>
public struct Rect : IEquatable<Rect>
{
/// <summary>
/// Получает пустой прямоугольник (позиция (0, 0), размеры (0, 0)).
/// </summary>
public static readonly Rect Empty = new(0, 0, 0, 0);
/// <summary>
/// Координата X левого верхнего угла прямоугольника.
/// </summary>
public double X { get; set; }
/// <summary>
/// Координата Y левого верхнего угла прямоугольника.
/// </summary>
public double Y { get; set; }
/// <summary>
/// Ширина прямоугольника.
/// </summary>
public double Width { get; set; }
/// <summary>
/// Высота прямоугольника.
/// </summary>
public double Height { get; set; }
/// <summary>
/// Получает координату X правого края прямоугольника.
/// </summary>
public double Right => X + Width;
/// <summary>
/// Получает координату Y нижнего края прямоугольника.
/// </summary>
public double Bottom => Y + Height;
/// <summary>
/// Получает левый верхний угол прямоугольника.
/// </summary>
public Point TopLeft => new(X, Y);
/// <summary>
/// Получает правый нижний угол прямоугольника.
/// </summary>
public Point BottomRight => new(Right, Bottom);
/// <summary>
/// Получает центр прямоугольника.
/// </summary>
public Point Center => new(X + Width / 2, Y + Height / 2);
/// <summary>
/// Получает площадь прямоугольника.
/// </summary>
public double Area => Width * Height;
/// <summary>
/// Инициализирует новый прямоугольник с указанными параметрами.
/// </summary>
/// <param name="x">Координата X.</param>
/// <param name="y">Координата Y.</param>
/// <param name="width">Ширина.</param>
/// <param name="height">Высота.</param>
public Rect(double x, double y, double width, double height)
{
X = x;
Y = y;
Width = width;
Height = height;
}
/// <summary>
/// Инициализирует новый прямоугольник с указанной позицией и размером.
/// </summary>
/// <param name="location">Позиция прямоугольника.</param>
/// <param name="size">Размер прямоугольника.</param>
public Rect(Point location, Size size)
{
X = location.X;
Y = location.Y;
Width = size.Width;
Height = size.Height;
}
/// <summary>
/// Создает прямоугольник из System.Drawing.Rectangle.
/// </summary>
public static Rect FromDrawingRectangle(System.Drawing.Rectangle rect) =>
new(rect.X, rect.Y, rect.Width, rect.Height);
/// <summary>
/// Преобразует прямоугольник в System.Drawing.Rectangle.
/// </summary>
public System.Drawing.Rectangle ToDrawingRectangle() =>
new((int)X, (int)Y, (int)Width, (int)Height);
/// <summary>
/// Проверяет, содержит ли прямоугольник указанную точку.
/// </summary>
public bool Contains(Point point) =>
point.X >= X && point.X <= Right &&
point.Y >= Y && point.Y <= Bottom;
/// <summary>
/// Проверяет, пересекается ли этот прямоугольник с другим.
/// </summary>
public bool Intersects(Rect other) =>
X < other.Right && Right > other.X &&
Y < other.Bottom && Bottom > other.Y;
/// <summary>
/// Определяет, равен ли этот прямоугольник другому прямоугольнику.
/// </summary>
public bool Equals(Rect other) =>
Math.Abs(X - other.X) < double.Epsilon &&
Math.Abs(Y - other.Y) < double.Epsilon &&
Math.Abs(Width - other.Width) < double.Epsilon &&
Math.Abs(Height - other.Height) < double.Epsilon;
/// <inheritdoc/>
public override bool Equals(object? obj) =>
obj is Rect rect && Equals(rect);
/// <inheritdoc/>
public override int GetHashCode() =>
HashCode.Combine(X, Y, Width, Height);
/// <summary>
/// Определяет, равны ли два прямоугольника.
/// </summary>
public static bool operator ==(Rect left, Rect right) =>
left.Equals(right);
/// <summary>
/// Определяет, не равны ли два прямоугольника.
/// </summary>
public static bool operator !=(Rect left, Rect right) =>
!left.Equals(right);
/// <summary>
/// Возвращает строковое представление прямоугольника.
/// </summary>
public override string ToString() =>
$"[X={X:F2}, Y={Y:F2}, Width={Width:F2}, Height={Height:F2}]";
}

View File

@@ -0,0 +1,84 @@
namespace Lattice.Core.Geometry;
/// <summary>
/// Представляет размеры в двумерном пространстве с шириной и высотой.
/// Эта структура является платформонезависимой и может использоваться
/// во всех слоях системы Lattice.
/// </summary>
public struct Size : IEquatable<Size>
{
/// <summary>
/// Получает размер с нулевой шириной и высотой.
/// </summary>
public static readonly Size Zero = new(0, 0);
/// <summary>
/// Ширина.
/// </summary>
public double Width { get; set; }
/// <summary>
/// Высота.
/// </summary>
public double Height { get; set; }
/// <summary>
/// Получает признак того, что размер является пустым (нулевая ширина или высота).
/// </summary>
public bool IsEmpty => Width <= 0 || Height <= 0;
/// <summary>
/// Инициализирует новый размер с указанными значениями.
/// </summary>
/// <param name="width">Ширина.</param>
/// <param name="height">Высота.</param>
public Size(double width, double height)
{
Width = width;
Height = height;
}
/// <summary>
/// Создает размер из System.Drawing.Size.
/// </summary>
public static Size FromDrawingSize(System.Drawing.Size size) =>
new(size.Width, size.Height);
/// <summary>
/// Преобразует размер в System.Drawing.Size.
/// </summary>
public System.Drawing.Size ToDrawingSize() =>
new((int)Width, (int)Height);
/// <summary>
/// Определяет, равен ли этот размер другому размеру.
/// </summary>
public bool Equals(Size other) =>
Math.Abs(Width - other.Width) < double.Epsilon &&
Math.Abs(Height - other.Height) < double.Epsilon;
/// <inheritdoc/>
public override bool Equals(object? obj) =>
obj is Size size && Equals(size);
/// <inheritdoc/>
public override int GetHashCode() =>
HashCode.Combine(Width, Height);
/// <summary>
/// Определяет, равны ли два размера.
/// </summary>
public static bool operator ==(Size left, Size right) =>
left.Equals(right);
/// <summary>
/// Определяет, не равны ли два размера.
/// </summary>
public static bool operator !=(Size left, Size right) =>
!left.Equals(right);
/// <summary>
/// Возвращает строковое представление размера.
/// </summary>
public override string ToString() => $"{Width} × {Height}";
}

View File

@@ -1,27 +0,0 @@
namespace Lattice.Core.Abstractions;
/// <summary>
/// Сервис управления контекстом приложения и связанными командами.
/// </summary>
public interface IContextService
{
/// <summary>
/// Имя текущего активного контекста.
/// </summary>
string CurrentContext { get; }
/// <summary>
/// Возникает при смене фокуса между вкладками с разными ContextGroup.
/// </summary>
event EventHandler<string>? ContextChanged;
/// <summary>
/// Устанавливает активный контекст. Вызывается UI-слоем при активации вкладки.
/// </summary>
void SetContext(string contextGroup);
/// <summary>
/// Проверяет, должна ли команда быть видимой в текущем контексте.
/// </summary>
bool IsCommandVisible(string commandId, string commandContext);
}

View File

@@ -1,33 +0,0 @@
namespace Lattice.Core.Abstractions;
/// <summary>
/// Описывает компонент, который может быть размещен внутри узла компоновки Lattice.
/// </summary>
public interface IDockableComponent
{
/// <summary>
/// Уникальный строковый идентификатор компонента (например, "SolutionExplorer").
/// </summary>
string UniqueId { get; }
/// <summary>
/// Заголовок, отображаемый на вкладке или в заголовке панели.
/// </summary>
string DisplayName { get; }
/// <summary>
/// Ключ иконки (для Segoe Fluent Icons или путей к ресурсам).
/// </summary>
string? IconKey { get; }
/// <summary>
/// Группа контекста (например, "CodeEditor", "Debugger").
/// Определяет, какие панели инструментов будут активны.
/// </summary>
string ContextGroup { get; }
/// <summary>
/// Указывает, разрешено ли закрывать данный компонент пользователем.
/// </summary>
bool CanClose { get; }
}

View File

@@ -1,42 +0,0 @@
namespace Lattice.Core.Abstractions;
/// <summary>
/// Представляет базовый элемент иерархии компоновки Lattice.
/// </summary>
public interface ILayoutElement
{
/// <summary>
/// Уникальный идентификатор элемента.
/// </summary>
Guid Id { get; }
/// <summary>
/// Имя элемента для отображения или идентификации в логах.
/// </summary>
string Name { get; set; }
/// <summary>
/// Значение ширины (в пикселях или долях "star").
/// </summary>
double WidthValue { get; set; }
/// <summary>
/// Указывает, является ли ширина пропорциональной (star).
/// </summary>
bool IsWidthStar { get; set; }
/// <summary>
/// Значение высоты (в пикселях или долях "star").
/// </summary>
double HeightValue { get; set; }
/// <summary>
/// Указывает, является ли высота пропорциональной (star).
/// </summary>
bool IsHeightStar { get; set; }
/// <summary>
/// Родительский элемент в дереве компоновки.
/// </summary>
ILayoutElement? Parent { get; set; }
}

View File

@@ -1,40 +0,0 @@
using Lattice.Core.Models;
using Lattice.Core.Models.Enums;
namespace Lattice.Core.Abstractions;
/// <summary>
/// Сервис управления жизненным циклом макета приложения.
/// </summary>
public interface ILayoutService
{
/// <summary>
/// Текущий корневой узел всей структуры окон.
/// </summary>
LayoutNode? Root { get; }
/// <summary>
/// Событие, возникающее при любом изменении структуры (докинг, закрытие, изменение размеров).
/// </summary>
event EventHandler? LayoutUpdated;
/// <summary>
/// Перемещает узел в указанную позицию относительно целевого узла.
/// </summary>
void Dock(LayoutNode source, LayoutNode target, DockDirection direction);
/// <summary>
/// Удаляет узел из макета (например, при закрытии вкладки).
/// </summary>
void Remove(LayoutNode node);
/// <summary>
/// Импортирует структуру макета из снапшота.
/// </summary>
void LoadLayout(string jsonData);
/// <summary>
/// Экспортирует текущую структуру в строку для сохранения.
/// </summary>
string SaveLayout();
}

View File

@@ -1,17 +0,0 @@
using Lattice.Core.Models;
using Lattice.Core.Models.Enums;
namespace Lattice.Core.Abstractions;
/// <summary>
/// Описывает сервис для рассылки уведомлений внутри системы Lattice.
/// </summary>
public interface INotificationService
{
/// <summary>
/// Событие, возникающее при отправке нового сообщения.
/// </summary>
event EventHandler<NotificationEventArgs> NotificationReceived;
void Show(string message, NotificationSeverity severity = NotificationSeverity.Info, int durationSeconds = 5);
}

View File

@@ -1,25 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Lattice.Core</AssemblyName>
<RootNamespace>Lattice.Core</RootNamespace>
<Authors>FrigaT</Authors>
<Company>FrigaT</Company>
<RepositoryUrl>https://git.frigat.duckdns.org/FrigaT/Lattice</RepositoryUrl>
<PackageProjectUrl>https://git.frigat.duckdns.org/FrigaT/Lattice</PackageProjectUrl>
<Description>Core docking and layout engine for Lattice UI (WinUI 3 / Uno Platform).</Description>
<IsTrimmable Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">true</IsTrimmable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="System.Text.Json" Version="10.0.1" />
</ItemGroup>
</Project>

View File

@@ -1,37 +0,0 @@
namespace Lattice.Core.Models;
/// <summary>
/// Определение действия (команды), которое может быть отображено в интерфейсе.
/// </summary>
public record ActionDefinition
{
/// <summary>
/// Уникальный идентификатор команды.
/// </summary>
public string Id { get; init; } = Guid.NewGuid().ToString();
/// <summary>
/// Текст кнопки, отображаемый пользователю.
/// </summary>
public string Label { get; init; } = "Action";
/// <summary>
/// Код иконки из шрифта Segoe Fluent Icons (например, "\uE102").
/// </summary>
public string IconKey { get; init; } = "\uE102";
/// <summary>
/// Группа контекста, к которой привязана кнопка (например, "CodeEditor", "Common").
/// </summary>
public string TargetContext { get; init; } = "Common";
/// <summary>
/// Указывает, активна ли кнопка в данный момент.
/// </summary>
public bool IsEnabled { get; set; } = true;
/// <summary>
/// Подсказка, отображаемая при наведении (Tooltip).
/// </summary>
public string Tooltip { get; init; } = string.Empty;
}

View File

@@ -1,29 +0,0 @@
using Lattice.Core.Abstractions;
namespace Lattice.Core.Models;
/// <summary>
/// Узел, представляющий конечный контент (вкладку, панель инструментов или документ).
/// </summary>
public class ContentNode : LayoutNode
{
/// <summary>
/// Ссылка на визуальный или логический компонент, закрепленный в этом узле.
/// </summary>
public IDockableComponent? Component { get; set; }
/// <summary>
/// Указывает, является ли данный узел частью основной рабочей области документов.
/// </summary>
public bool IsDocumentArea { get; set; }
/// <summary>
/// Инициализирует новый экземпляр <see cref="ContentNode"/> на основе компонента.
/// </summary>
/// <param name="component">Компонент содержимого.</param>
public ContentNode(IDockableComponent component)
{
Component = component;
Name = component.DisplayName;
}
}

View File

@@ -1,11 +0,0 @@
namespace Lattice.Core.Models.Enums;
public enum DockDirection
{
Center,
Left,
Right,
Top,
Bottom,
Floating,
}

View File

@@ -1,8 +0,0 @@
namespace Lattice.Core.Models.Enums;
public enum NotificationSeverity {
Info,
Success,
Warning,
Error,
}

View File

@@ -1,13 +0,0 @@
namespace Lattice.Core.Models.Enums;
public enum SplitOrientation
{
/// <summary>
/// Элементы располагаются друг за другом по горизонтали
/// </summary>
Horizontal,
/// <summary>
/// Элементы располагаются друг за другом по вертикали
/// </summary>
Vertical,
}

View File

@@ -1,35 +0,0 @@
using Lattice.Core.Abstractions;
namespace Lattice.Core.Models;
/// <summary>
/// Абстрактный базовый класс для всех узлов дерева компоновки.
/// </summary>
public abstract class LayoutNode : ILayoutElement
{
/// <inheritdoc/>
public Guid Id { get; } = Guid.NewGuid();
/// <inheritdoc/>
public string Name { get; set; } = string.Empty;
/// <inheritdoc/>
public double WidthValue { get; set; } = 1.0;
/// <inheritdoc/>
public bool IsWidthStar { get; set; } = true;
/// <inheritdoc/>
public double HeightValue { get; set; } = 1.0;
/// <inheritdoc/>
public bool IsHeightStar { get; set; } = true;
/// <inheritdoc/>
public ILayoutElement? Parent { get; set; }
/// <summary>
/// Возвращает строковое представление узла для отладки.
/// </summary>
public override string ToString() => $"{GetType().Name} [{Name}] ({Id.ToString()[..4]})";
}

View File

@@ -1,5 +0,0 @@
using Lattice.Core.Models.Enums;
namespace Lattice.Core.Models;
public record NotificationEventArgs(string Message, NotificationSeverity Severity, int DurationSeconds);

View File

@@ -1,38 +0,0 @@
using Lattice.Core.Models.Enums;
namespace Lattice.Core.Models;
/// <summary>
/// Узел-контейнер, разделяющий пространство между дочерними элементами в определенной ориентации.
/// </summary>
public class SplitContainerNode : LayoutNode
{
/// <summary>
/// Ориентация разделения (горизонтальная или вертикальная).
/// </summary>
public SplitOrientation Orientation { get; set; }
/// <summary>
/// Список дочерних узлов, находящихся внутри данного контейнера.
/// </summary>
public List<LayoutNode> Children { get; } = new();
/// <summary>
/// Инициализирует новый экземпляр <see cref="SplitContainerNode"/>.
/// </summary>
/// <param name="orientation">Ориентация контейнера.</param>
public SplitContainerNode(SplitOrientation orientation)
{
Orientation = orientation;
}
/// <summary>
/// Добавляет дочерний узел в контейнер и устанавливает связь с родителем.
/// </summary>
/// <param name="child">Узел для добавления.</param>
public void AddChild(LayoutNode child)
{
child.Parent = this;
Children.Add(child);
}
}

View File

@@ -1,12 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Lattice.Core.Models
{
internal class WorkspaceSnapshot
{
}
}

View File

@@ -1,31 +0,0 @@
using Lattice.Core.Models;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Lattice.Core.Persistence;
/// <summary>
/// Конвертер для полиморфной сериализации и десериализации узлов дерева Lattice.
/// </summary>
public class LayoutJsonConverter : JsonConverter<LayoutNode>
{
public override LayoutNode? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var jsonDoc = JsonDocument.ParseValue(ref reader);
var rootElement = jsonDoc.RootElement;
// Определяем тип узла по наличию специфических свойств
if (rootElement.TryGetProperty("Orientation", out _))
{
return JsonSerializer.Deserialize<SplitContainerNode>(rootElement.GetRawText(), options);
}
return JsonSerializer.Deserialize<ContentNode>(rootElement.GetRawText(), options);
}
public override void Write(Utf8JsonWriter writer, LayoutNode value, JsonSerializerOptions options)
{
// Используем стандартную сериализацию для конкретных типов
JsonSerializer.Serialize(writer, (object)value, value.GetType(), options);
}
}

View File

@@ -1,54 +0,0 @@
# Lattice.Core
[![Framework](img.shields.io)](#)
[![Author](img.shields.io)](git.frigat.duckdns.org)
[![Platform](img.shields.io)](#)
**Lattice.Core** — это платформонезависимое ядро (Layout Engine) для построения сложных интерфейсов с системой докинга в стиле Visual Studio 2026.
Библиотека является частью экосистемы **Lattice** и отвечает исключительно за математику макета, управление деревом узлов и контекстное состояние, не имея зависимостей от конкретных UI-фреймворков.
## 🚀 Особенности
- **Агностическая архитектура**: Полная совместимость с .NET 8+, WinUI 3 и Uno Platform.
- **Древовидная компоновка**: Управление интерфейсом через узлы (`Split` и `Content`).
- **Context-Aware System**: Встроенный сервис отслеживания контекста для динамического переключения панелей инструментов.
- **Smart Docking**: Алгоритмы автоматического разделения зон и схлопывания пустых контейнеров.
- **JSON Persistence**: Полиморфная сериализация макетов для сохранения и загрузки состояний пользователя.
## 📁 Структура проекта
* `Abstractions/` — Интерфейсы для расширения системы.
* `Models/` — Базовые сущности дерева (узлы, направления, ориентация).
* `Services/` - Сервисы управления интерфейсом
* `ContextService` - Сервис управления контекстом приложения.
* `LayoutService` - Сервис управления макетом.
* `NotificationService` - Сервис уведомлений.
* `Persistence/` — Логика сохранения макета в JSON.
## 🛠 Использование
### Создание базового макета
```csharp
var layoutService = new LayoutService();
// Создаем контентные узлы
var explorer = new ContentNode(new MyToolComponent("Solution Explorer", "Explorer"));
var editor = new ContentNode(new MyDocumentComponent("Main.cs", "CodeEditor"));
// Устанавливаем редактор как корень
layoutService.SetRoot(editor);
// Прикрепляем проводник слева от редактора
layoutService.Dock(explorer, editor, DockDirection.Left);
//Переключение контекста
var contextService = new ContextService();
// Вызывается при активации вкладки в UI
contextService.SetContext("CodeEditor");
// Проверка видимости команд в текущем контексте
bool isDebugVisible = contextService.IsCommandVisible("btnDebug", "CodeEditor");
```

View File

@@ -1,39 +0,0 @@
using Lattice.Core.Abstractions;
namespace Lattice.Core.Services;
/// <summary>
/// Реализация сервиса управления контекстом приложения.
/// </summary>
public class ContextService : IContextService
{
private string _currentContext = "Common";
/// <inheritdoc/>
public string CurrentContext => _currentContext;
/// <inheritdoc/>
public event EventHandler<string>? ContextChanged;
/// <inheritdoc/>
public void SetContext(string contextGroup)
{
if (string.IsNullOrWhiteSpace(contextGroup)) contextGroup = "Common";
if (_currentContext != contextGroup)
{
_currentContext = contextGroup;
ContextChanged?.Invoke(this, contextGroup);
}
}
/// <inheritdoc/>
public bool IsCommandVisible(string commandId, string commandContext)
{
// Базовая логика: команда видима, если её контекст совпадает с текущим
// или если команда помечена как общая ("Common" или "Global").
return commandContext == "Common" ||
commandContext == "Global" ||
commandContext == _currentContext;
}
}

View File

@@ -1,228 +0,0 @@
using Lattice.Core.Abstractions;
using Lattice.Core.Models;
using Lattice.Core.Models.Enums;
using Lattice.Core.Persistence;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Lattice.Core.Services;
/// <summary>
/// Реализация сервиса управления макетом.
/// </summary>
public class LayoutService : ILayoutService
{
private readonly ILogger? _logger;
private LayoutNode? _root;
/// <inheritdoc/>
public LayoutNode? Root => _root;
/// <inheritdoc/>
public event EventHandler? LayoutUpdated;
public LayoutService(ILogger<LayoutService>? logger = null)
{
_logger = logger;
}
/// <inheritdoc/>
public void Dock(LayoutNode source, LayoutNode target, DockDirection direction)
{
if (source == target) return;
_logger?.LogDebug("Начало трансформации дерева: {Source} -> {Target} ({Direction})", source.Name, target.Name, direction);
// 1. Извлекаем источник из его текущего места в дереве
Remove(source);
// 2. Если докинг в центр — это логика объединения (например, в TabView)
// В рамках Core это может означать добавление в тот же контейнер
if (direction == DockDirection.Center)
{
HandleCenterDock(source, target);
}
else
{
// 3. Создаем разделение (Split)
HandleSideDock(source, target, direction);
}
LayoutUpdated?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Логика добавления элемента в центральную часть (вкладки).
/// </summary>
private void HandleCenterDock(LayoutNode source, LayoutNode target)
{
if (target.Parent is SplitContainerNode parent)
{
parent.AddChild(source);
}
else if (target == _root)
{
// Если таргет - корень, и мы докаем в центр, создаем контейнер по умолчанию
var container = new SplitContainerNode(SplitOrientation.Horizontal);
_root = container;
container.AddChild(target);
container.AddChild(source);
}
}
/// <summary>
/// Логика разделения существующей области на две (Side Dock).
/// </summary>
private void HandleSideDock(LayoutNode source, LayoutNode target, DockDirection direction)
{
var orientation = (direction == DockDirection.Left || direction == DockDirection.Right)
? SplitOrientation.Horizontal
: SplitOrientation.Vertical;
var parent = target.Parent as SplitContainerNode;
// Создаем новый сплиттер, который заменит target
var newContainer = new SplitContainerNode(orientation);
if (parent != null)
{
// Заменяем target на новый контейнер в списке детей родителя
int index = parent.Children.IndexOf(target);
parent.Children[index] = newContainer;
newContainer.Parent = parent;
}
else
{
// Если родителя нет, значит target был корнем
_root = newContainer;
}
// Настраиваем порядок в новом сплиттере
if (direction == DockDirection.Left || direction == DockDirection.Top)
{
newContainer.AddChild(source);
newContainer.AddChild(target);
}
else
{
newContainer.AddChild(target);
newContainer.AddChild(source);
}
// Корректируем размеры (например, делим пополам)
source.WidthValue = 0.5;
target.WidthValue = 0.5;
source.IsWidthStar = true;
target.IsWidthStar = true;
}
/// <inheritdoc/>
public void Remove(LayoutNode node)
{
if (node.Parent is SplitContainerNode parent)
{
parent.Children.Remove(node);
node.Parent = null;
// Если в контейнере остался один элемент — убираем лишнюю вложенность
if (parent.Children.Count == 1)
{
CollapseContainer(parent);
}
}
else if (node == _root)
{
_root = null;
}
LayoutUpdated?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Убирает ненужные контейнеры, если в них остался только один элемент.
/// </summary>
private void CollapseContainer(SplitContainerNode container)
{
var lastChild = container.Children[0];
var parent = container.Parent as SplitContainerNode;
if (parent != null)
{
int index = parent.Children.IndexOf(container);
parent.Children[index] = lastChild;
lastChild.Parent = parent;
}
else
{
_root = lastChild;
lastChild.Parent = null;
}
}
/// <inheritdoc/>
public string SaveLayout()
{
if (_root == null) return string.Empty;
var options = GetJsonOptions();
try
{
string json = JsonSerializer.Serialize(_root, options);
_logger?.LogInformation("Макет успешно экспортирован в JSON. Длина: {Length}", json.Length);
return json;
}
catch (Exception ex)
{
_logger?.LogError(ex, "Ошибка при сохранении макета Lattice");
return string.Empty;
}
}
/// <inheritdoc/>
public void LoadLayout(string jsonData)
{
if (string.IsNullOrWhiteSpace(jsonData)) return;
var options = GetJsonOptions();
try
{
var importedRoot = JsonSerializer.Deserialize<LayoutNode>(jsonData, options);
if (importedRoot != null)
{
// При загрузке нужно восстановить связи Parent, так как они не сериализуются (циклические ссылки)
RestoreParentLinks(importedRoot, null);
_root = importedRoot;
_logger?.LogInformation("Макет успешно загружен. Корневой узел: {Id}", _root.Id);
LayoutUpdated?.Invoke(this, EventArgs.Empty);
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "Ошибка при десериализации макета Lattice");
}
}
private JsonSerializerOptions GetJsonOptions()
{
return new JsonSerializerOptions
{
WriteIndented = true,
Converters = { new LayoutJsonConverter() },
// Игнорируем циклы, так как мы восстановим Parent вручную
ReferenceHandler = ReferenceHandler.IgnoreCycles
};
}
private void RestoreParentLinks(LayoutNode node, LayoutNode? parent)
{
node.Parent = parent;
if (node is SplitContainerNode container)
{
foreach (var child in container.Children)
{
RestoreParentLinks(child, container);
}
}
}
}

View File

@@ -1,19 +0,0 @@
using Lattice.Core.Abstractions;
using Lattice.Core.Models;
using Lattice.Core.Models.Enums;
namespace Lattice.Core.Services;
/// <summary>
/// Простая реализация сервиса уведомлений.
/// Хранит только событие и вызывает его при Show().
/// </summary>
public sealed class NotificationService : INotificationService
{
public event EventHandler<NotificationEventArgs>? NotificationReceived;
public void Show(string message, NotificationSeverity severity = NotificationSeverity.Info, int durationSeconds = 5)
{
NotificationReceived?.Invoke(this, new NotificationEventArgs(message, severity, durationSeconds));
}
}

19
Lattice.IDE/App.xaml Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<Application
x:Class="Lattice.IDE.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:lt="using:Lattice.Themes"
xmlns:local="using:Lattice.IDE">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
<ResourceDictionary Source="ms-appx:///Lattice.UI.Docking.WinUI/Themes/Generic.xaml" />
<!-- Other merged dictionaries here -->
</ResourceDictionary.MergedDictionaries>
<!-- Other app resources here -->
</ResourceDictionary>
</Application.Resources>
</Application>

37
Lattice.IDE/App.xaml.cs Normal file
View File

@@ -0,0 +1,37 @@
using Lattice.Themes;
using Microsoft.UI.Xaml;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace Lattice.IDE
{
/// <summary>
/// Provides application-specific behavior to supplement the default Application class.
/// </summary>
public partial class App : Application
{
private Window? _window;
/// <summary>
/// Initializes the singleton application object. This is the first line of authored code
/// executed, and as such is the logical equivalent of main() or WinMain().
/// </summary>
public App()
{
InitializeComponent();
}
/// <summary>
/// Invoked when the application is launched.
/// </summary>
/// <param name="args">Details about the launch request and process.</param>
protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
{
ThemeManager.Current.ApplyTheme(new FluentThemePack());
_window = new MainWindow();
_window.Activate();
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<UserControl
x:Class="Lattice.IDE.Controls.EditorView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Lattice.IDE.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid Background="{ThemeResource Lattice.Brush.Background.Secondary}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<!-- Полоса номеров строк -->
<ColumnDefinition Width="*"/>
<!-- Текст кода -->
</Grid.ColumnDefinitions>
<!-- Левая панель с номерами строк -->
<StackPanel Grid.Column="0" Background="{ThemeResource Lattice.Brush.Background.Primary}" Padding="10,5">
<TextBlock Text="1" Foreground="Gray" FontFamily="Cascadia Code, Consolas"/>
<TextBlock Text="2" Foreground="Gray" FontFamily="Cascadia Code, Consolas"/>
<TextBlock Text="3" Foreground="Gray" FontFamily="Cascadia Code, Consolas"/>
<TextBlock Text="4" Foreground="Gray" FontFamily="Cascadia Code, Consolas"/>
<TextBlock Text="5" Foreground="Gray" FontFamily="Cascadia Code, Consolas"/>
<TextBlock Text="6" Foreground="Gray" FontFamily="Cascadia Code, Consolas"/>
</StackPanel>
<!-- Основная область редактирования -->
<TextBox Grid.Column="1"
AcceptsReturn="True"
IsSpellCheckEnabled="False"
TextWrapping="NoWrap"
FontFamily="Cascadia Code, Consolas"
FontSize="14"
BorderThickness="0"
Padding="10"
Background="Transparent"
Foreground="{ThemeResource TextFillColorPrimaryBrush}"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"
xml:space="preserve">
<TextBox.Text>using System;
using Lattice.Core;
namespace Lattice.IDE.Demo;
public class Program
{
public void Main()
{
Console.WriteLine("Hello, Lattice 2026!");
}
}
</TextBox.Text>
</TextBox>
</Grid>
</UserControl>

View File

@@ -0,0 +1,28 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Navigation;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices.WindowsRuntime;
using Windows.Foundation;
using Windows.Foundation.Collections;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace Lattice.IDE.Controls
{
public sealed partial class EditorView : UserControl
{
public EditorView()
{
InitializeComponent();
}
}
}

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<UserControl
x:Class="Lattice.IDE.Controls.SolutionExplorerView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Lattice.IDE.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid Background="{ThemeResource LayerFillColorDefaultBrush}" Padding="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Text="SOLUTION 'LATTICE' (2026)"
FontSize="11"
FontWeight="Bold"
Opacity="0.6"/>
<TreeView Grid.Row="1" Margin="0,10,0,0">
<TreeView.RootNodes>
<TreeViewNode Content="Lattice.Core.Docking" IsExpanded="True">
<TreeViewNode.Children>
<TreeViewNode Content="Models" />
<TreeViewNode Content="Engine" />
</TreeViewNode.Children>
</TreeViewNode>
<TreeViewNode Content="Lattice.UI.Docking.WinUI" IsExpanded="True">
<TreeViewNode.Children>
<TreeViewNode Content="Controls" />
<TreeViewNode Content="Themes" />
</TreeViewNode.Children>
</TreeViewNode>
</TreeView.RootNodes>
</TreeView>
</Grid>
</UserControl>

View File

@@ -0,0 +1,28 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Navigation;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices.WindowsRuntime;
using Windows.Foundation;
using Windows.Foundation.Collections;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace Lattice.IDE.Controls
{
public sealed partial class SolutionExplorerView : UserControl
{
public SolutionExplorerView()
{
InitializeComponent();
}
}
}

View File

@@ -0,0 +1,81 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFrameworks>net8.0-windows10.0.19041.0;net9.0-windows10.0.19041.0;net10.0-windows10.0.19041.0</TargetFrameworks>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<RootNamespace>Lattice.IDE</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Platforms>x86;x64;ARM64</Platforms>
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
<PublishProfile>win-$(Platform).pubxml</PublishProfile>
<UseWinUI>true</UseWinUI>
<WinUISDKReferences>false</WinUISDKReferences>
<EnableMsixTooling>true</EnableMsixTooling>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<None Remove="Controls\EditorView.xaml" />
<None Remove="Controls\SolutionExplorerView.xaml" />
</ItemGroup>
<ItemGroup>
<Content Include="Assets\SplashScreen.scale-200.png" />
<Content Include="Assets\LockScreenLogo.scale-200.png" />
<Content Include="Assets\Square150x150Logo.scale-200.png" />
<Content Include="Assets\Square44x44Logo.scale-200.png" />
<Content Include="Assets\Square44x44Logo.targetsize-24_altform-unplated.png" />
<Content Include="Assets\StoreLogo.png" />
<Content Include="Assets\Wide310x150Logo.scale-200.png" />
</ItemGroup>
<ItemGroup>
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
<!--
Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging
Tools extension to be activated for this project even if the Windows App SDK Nuget
package has not yet been restored.
-->
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<ProjectCapability Include="Msix" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.7463" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.251106002" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lattice.Core.Docking\Lattice.Core.Docking.csproj" />
<ProjectReference Include="..\Lattice.Themes.Core\Lattice.Themes.Core.csproj" />
<ProjectReference Include="..\Lattice.Themes.Fluent\Lattice.Themes.Fluent.csproj" />
<ProjectReference Include="..\Lattice.Themes.VS2026\Lattice.Themes.VS2026.csproj" />
<ProjectReference Include="..\Lattice.UI.Docking.WinUI\Lattice.UI.Docking.WinUI.csproj" />
</ItemGroup>
<ItemGroup>
<Page Update="Controls\EditorView.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>
<Page Update="Controls\SolutionExplorerView.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<!--
Defining the "HasPackageAndPublishMenuAddedByProject" property here allows the Solution
Explorer "Package and Publish" context menu entry to be enabled for this project even if
the Windows App SDK Nuget package has not yet been restored.
-->
<PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
</PropertyGroup>
<!-- Publish Properties -->
<PropertyGroup>
<PublishReadyToRun Condition="'$(Configuration)' == 'Debug'">False</PublishReadyToRun>
<PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun>
<PublishTrimmed Condition="'$(Configuration)' == 'Debug'">False</PublishTrimmed>
<PublishTrimmed Condition="'$(Configuration)' != 'Debug'">True</PublishTrimmed>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,28 @@
using Lattice.Core.Docking.Abstractions;
namespace Lattice.IDE;
/// <summary>
/// Реализация контента для демонстрации, принимающая любой UI-объект.
/// </summary>
public class DemoContent : IDockContent
{
public string Id { get; }
public string Title { get; set; }
/// <summary>
/// Сюда мы передаем наш UserControl (SolutionExplorerView и т.д.)
/// </summary>
public object View { get; set; }
public bool CanClose { get; set; } = true;
public DemoContent(string id, string title, object view)
{
Id = id;
Title = title;
View = view;
}
public bool OnClosing() => true;
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<Window
x:Class="Lattice.IDE.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Lattice.IDE"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:lattice="using:Lattice.UI"
mc:Ignorable="d"
Title="Lattice.IDE">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Меню управления Demo -->
<StackPanel Orientation="Horizontal" Spacing="10" Padding="10" Background="{ThemeResource SystemControlBackgroundChromeMediumLowBrush}">
<Button Content="Fluent UI Theme" Click="SetFluentTheme"/>
<Button Content="VS 2026 Theme" Click="SetVSTheme"/>
<TextBlock Text="Lattice IDE Demo 2026" VerticalAlignment="Center" Margin="20,0" FontWeight="Bold"/>
</StackPanel>
<!-- Lattice Docking Host -->
<lattice:LatticeDockHost x:Name="DockHost" Grid.Row="1" />
</Grid>
</Window>

View File

@@ -0,0 +1,64 @@
using Lattice.Core.Docking.Engine;
using Lattice.Core.Docking.Models;
using Lattice.IDE.Controls;
using Lattice.Themes;
using Microsoft.UI.Xaml;
// Ïðåäïîëîæèì, ÷òî VS2026Theme òîæå ðåàëèçîâàí àíàëîãè÷íî Fluent
// using Lattice.Themes.VisualStudio2026;
namespace Lattice.IDE;
public sealed partial class MainWindow : Window
{
private LayoutManager _manager;
public MainWindow()
{
this.InitializeComponent();
WindowTracker.Register(this);
SystemBackdrop = new Microsoft.UI.Xaml.Media.MicaBackdrop();
InitLattice();
}
private void InitLattice()
{
_manager = new LayoutManager();
// Ñîçäàåì êîíòåíò íà îñíîâå XAML UserControls
var solutionExplorer = new DemoContent(
"sln",
"Solution Explorer",
new SolutionExplorerView()
);
var editor = new DemoContent(
"code_01",
"Program.cs",
new EditorView()
);
// Ñîáèðàåì äåðåâî (êàê ðàíüøå)
var leftLeaf = new DockLeaf();
leftLeaf.AddContent(solutionExplorer);
var centerLeaf = new DockLeaf()
{
TabPlacement = TabPlacement.Top,
};
centerLeaf.AddContent(editor);
var rootGroup = new DockGroup(leftLeaf, centerLeaf, SplitDirection.Horizontal)
{
SplitRatio = 0.25
};
_manager.SetRoot(rootGroup);
DockHost.Manager = _manager;
}
private void SetFluentTheme(object sender, RoutedEventArgs e) =>
ThemeManager.Current.ApplyTheme(new FluentThemePack());
private void SetVSTheme(object sender, RoutedEventArgs e) =>
ThemeManager.Current.ApplyTheme(new VS2026ThemePack());
}

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity
Name="c6596033-98b6-4778-8280-bc9256b9be07"
Publisher="CN=frost"
Version="1.0.0.0" />
<mp:PhoneIdentity PhoneProductId="c6596033-98b6-4778-8280-bc9256b9be07" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
<Properties>
<DisplayName>Lattice.IDE</DisplayName>
<PublisherDisplayName>frost</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate"/>
</Resources>
<Applications>
<Application Id="App"
Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="Lattice.IDE"
Description="Lattice.IDE"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
<uap:SplashScreen Image="Assets\SplashScreen.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>

View File

@@ -0,0 +1,10 @@
{
"profiles": {
"Lattice.IDE (Package)": {
"commandName": "MsixPackage"
},
"Lattice.IDE (Unpackaged)": {
"commandName": "Project"
}
}
}

19
Lattice.IDE/app.manifest Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Lattice.IDE.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- The ID below informs the system that this application is compatible with OS features first introduced in Windows 10.
It is necessary to support features in unpackaged applications, for example the custom titlebar implementation.
For more info see https://docs.microsoft.com/windows/apps/windows-app-sdk/use-windows-app-sdk-run-time#declare-os-compatibility-in-your-application-manifest -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>

View File

@@ -0,0 +1,30 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Lattice.Layout.UI.WinUI.Controls;
/// <summary>
/// Контрол для отображения группы вкладок.
/// Содержит заголовки вкладок и область содержимого.
/// </summary>
public sealed class WinUIGroupControl : Grid
{
/// <summary>
/// Контрол TabView, содержащий вкладки и их содержимое.
/// </summary>
public TabView TabView { get; }
/// <summary>
/// Создаёт новый экземпляр <see cref="WinUIGroupControl"/>.
/// </summary>
public WinUIGroupControl()
{
TabView = new TabView
{
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch
};
Children.Add(TabView);
}
}

View File

@@ -0,0 +1,134 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using System;
namespace Lattice.Layout.UI.WinUI.Controls;
/// <summary>
/// Контрол для отображения содержимого конечного элемента раскладки.
/// Является контейнером для реального UI-контента, подставляемого через ContentResolver.
/// </summary>
public sealed class WinUIItemControl : ContentControl
{
/// <summary>
/// Идентификатор содержимого, связанного с данным элементом.
/// Используется для подстановки реального UI-контрола через ContentResolver.
/// </summary>
public string? ContentId
{
get => (string?)GetValue(ContentIdProperty);
set => SetValue(ContentIdProperty, value);
}
public static readonly DependencyProperty ContentIdProperty =
DependencyProperty.Register(
nameof(ContentId),
typeof(string),
typeof(WinUIItemControl),
new PropertyMetadata(default(string), OnContentIdChanged));
/// <summary>
/// Делегат, который должен вернуть реальный UI-контент по ContentId.
/// Устанавливается WinUILayoutHost.
/// </summary>
public Func<string, UIElement?>? ContentResolver
{
get => (Func<string, UIElement?>?)GetValue(ContentResolverProperty);
set => SetValue(ContentResolverProperty, value);
}
public static readonly DependencyProperty ContentResolverProperty =
DependencyProperty.Register(
nameof(ContentResolver),
typeof(Func<string, UIElement?>),
typeof(WinUIItemControl),
new PropertyMetadata(null, OnContentResolverChanged));
/// <summary>
/// Вызывается, когда контент успешно загружен.
/// </summary>
public event Action<WinUIItemControl>? ContentLoaded;
/// <summary>
/// Вызывается, когда контент был очищен (Detach).
/// </summary>
public event Action<WinUIItemControl>? ContentCleared;
/// <summary>
/// Создаёт новый экземпляр <see cref="WinUIItemControl"/>.
/// </summary>
public WinUIItemControl()
{
HorizontalContentAlignment = HorizontalAlignment.Stretch;
VerticalContentAlignment = VerticalAlignment.Stretch;
// Fallback-контент, если ContentResolver не установлен
Content = CreatePlaceholder();
}
private static void OnContentIdChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is WinUIItemControl control)
control.TryLoadContent();
}
private static void OnContentResolverChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is WinUIItemControl control)
control.TryLoadContent();
}
/// <summary>
/// Пытается загрузить реальный контент по ContentId.
/// </summary>
private void TryLoadContent()
{
if (ContentId is null || ContentResolver is null)
{
Content = CreatePlaceholder();
return;
}
var resolved = ContentResolver(ContentId);
if (resolved is null)
{
Content = CreatePlaceholder($"Контент '{ContentId}' не найден");
return;
}
Content = resolved;
ContentLoaded?.Invoke(this);
}
/// <summary>
/// Очищает контент (используется визуалом при Detach).
/// </summary>
public void ClearContent()
{
Content = CreatePlaceholder();
ContentCleared?.Invoke(this);
}
/// <summary>
/// Создаёт placeholder-контент, отображаемый до загрузки реального UI.
/// </summary>
private static UIElement CreatePlaceholder(string? message = null)
{
return new Border
{
Background = new SolidColorBrush(Microsoft.UI.Colors.Transparent),
BorderBrush = new SolidColorBrush(Microsoft.UI.Colors.Gray),
BorderThickness = new Thickness(1),
Padding = new Thickness(8),
Child = new TextBlock
{
Text = message ?? "Нет содержимого",
Opacity = 0.6,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
}
};
}
}

View File

@@ -0,0 +1,134 @@
using Lattice.Layout.Abstractions;
using Lattice.Layout.UI.Docking;
using Lattice.Layout.UI.WinUI.Docking;
using Lattice.Layout.UI.WinUI.Rendering;
using Lattice.Layout.UI.WinUI.Visuals;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System;
namespace Lattice.Layout.UI.WinUI.Controls;
/// <summary>
/// WinUI-контрол, отображающий дерево раскладки.
/// Оборачивает LayoutRenderer и размещает визуальное дерево внутри себя.
/// </summary>
public sealed class WinUILayoutHost : UserControl, ILayoutView
{
/// <summary>
/// Слой, в котором размещается визуальное дерево раскладки.
/// </summary>
public Grid LayoutLayer { get; }
/// <summary>
/// Слой для отображения подсветки зон докинга.
/// </summary>
public DockOverlayHost OverlayLayer { get; }
private readonly LayoutRenderer _renderer;
private readonly WinUIVisualFactory _factory;
/// <summary>
/// Функция, возвращающая UI-содержимое по ContentId.
/// Используется визуальными элементами для подстановки реального контрола.
/// </summary>
public Func<string, UIElement>? ContentResolver { get; set; }
/// <summary>
/// Корневой элемент раскладки, который необходимо отобразить.
/// </summary>
public ILayoutRoot? Root
{
get => (ILayoutRoot?)GetValue(RootProperty);
set => SetValue(RootProperty, value);
}
/// <summary>
/// Свойство зависимости для <see cref="Root"/>.
/// </summary>
public static readonly DependencyProperty RootProperty =
DependencyProperty.Register(
nameof(Root),
typeof(ILayoutRoot),
typeof(WinUILayoutHost),
new PropertyMetadata(null, OnRootChanged));
private static void OnRootChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is WinUILayoutHost host)
{
host.Refresh();
}
}
/// <summary>
/// Создаёт новый экземпляр <see cref="WinUILayoutHost"/>.
/// </summary>
public WinUILayoutHost()
{
LayoutLayer = new Grid();
OverlayLayer = new DockOverlayHost();
var rootGrid = new Grid();
rootGrid.Children.Add(LayoutLayer);
rootGrid.Children.Add(OverlayLayer);
Content = rootGrid;
_factory = new WinUIVisualFactory();
_renderer = new LayoutRenderer(_factory);
}
/// <summary>
/// Выполняет полную перерисовку визуального дерева.
/// </summary>
public void Refresh()
{
LayoutLayer.Children.Clear();
if (Root?.Child is null)
return;
var visual = _renderer.Build(Root.Child);
visual.Attach();
switch (visual)
{
case WinUISplitVisual splitVisual:
LayoutLayer.Children.Add(splitVisual.Control);
break;
case WinUIGroupVisual groupVisual:
LayoutLayer.Children.Add(groupVisual.Control);
break;
case WinUIItemVisual itemVisual:
LayoutLayer.Children.Add(itemVisual.Control);
break;
}
}
/// <summary>
/// Показывает подсветку зоны докинга для указанной цели.
/// </summary>
public void ShowDockOverlay(DockTarget target)
{
if (target.Visual is not IWinUIVisual winuiVisual)
return;
var control = winuiVisual.Control;
var bounds = control.TransformToVisual(LayoutLayer)
.TransformBounds(new Windows.Foundation.Rect(0, 0, control.ActualWidth, control.ActualHeight));
OverlayLayer.ShowOverlay(target, bounds);
}
/// <summary>
/// Скрывает подсветку зон докинга.
/// </summary>
public void HideDockOverlay()
{
OverlayLayer.HideOverlay();
}
}

View File

@@ -0,0 +1,85 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Lattice.Layout.UI.WinUI.Controls;
/// <summary>
/// Контейнер для отображения сплит-элемента раскладки.
/// Использует Grid и автоматически создаёт строки/столбцы под детей.
/// </summary>
public sealed class WinUISplitControl : Grid
{
/// <summary>
/// Ориентация сплита (горизонтальная или вертикальная).
/// </summary>
public Orientation LayoutOrientation
{
get => (Orientation)GetValue(LayoutOrientationProperty);
set => SetValue(LayoutOrientationProperty, value);
}
public static readonly DependencyProperty LayoutOrientationProperty =
DependencyProperty.Register(
nameof(LayoutOrientation),
typeof(Orientation),
typeof(WinUISplitControl),
new PropertyMetadata(Orientation.Horizontal, OnOrientationChanged));
private static void OnOrientationChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is WinUISplitControl control)
control.RebuildGrid();
}
public WinUISplitControl()
{
Loaded += (_, _) => RebuildGrid();
}
/// <summary>
/// Перестраивает структуру Grid в зависимости от ориентации и количества детей.
/// </summary>
public void RebuildGrid()
{
RowDefinitions.Clear();
ColumnDefinitions.Clear();
if (Children.Count == 0)
return;
if (LayoutOrientation == Orientation.Horizontal)
{
// Горизонтальный сплит → столбцы
for (int i = 0; i < Children.Count; i++)
{
ColumnDefinitions.Add(new ColumnDefinition
{
Width = new GridLength(1, GridUnitType.Star)
});
if (Children[i] is FrameworkElement fe) Grid.SetColumn(fe, i);
}
}
else
{
// Вертикальный сплит → строки
for (int i = 0; i < Children.Count; i++)
{
RowDefinitions.Add(new RowDefinition
{
Height = new GridLength(1, GridUnitType.Star)
});
if (Children[i] is FrameworkElement fe) Grid.SetColumn(fe, i);
}
}
}
/// <summary>
/// Добавляет дочерний элемент и перестраивает Grid.
/// </summary>
public new void ChildrenChanged()
{
RebuildGrid();
}
}

View File

@@ -0,0 +1,83 @@
using Lattice.Layout.Abstractions;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Windows.Foundation;
namespace Lattice.Layout.UI.WinUI.Docking;
/// <summary>
/// Полупрозрачная подсветка зоны докинга.
/// </summary>
public sealed class DockOverlay : Control
{
public static readonly DependencyProperty ZoneProperty =
DependencyProperty.Register(
nameof(Zone),
typeof(DockZone),
typeof(DockOverlay),
new PropertyMetadata(DockZone.Center, OnVisualPropertyChanged));
public static readonly DependencyProperty BoundsProperty =
DependencyProperty.Register(
nameof(Bounds),
typeof(Rect),
typeof(DockOverlay),
new PropertyMetadata(Rect.Empty, OnVisualPropertyChanged));
/// <summary>
/// Зона докинга, которую нужно подсветить.
/// </summary>
public DockZone Zone
{
get => (DockZone)GetValue(ZoneProperty);
set => SetValue(ZoneProperty, value);
}
/// <summary>
/// Прямоугольник зоны в координатах родительского контейнера.
/// </summary>
public Rect Bounds
{
get => (Rect)GetValue(BoundsProperty);
set => SetValue(BoundsProperty, value);
}
private Border? _border;
public DockOverlay()
{
IsHitTestVisible = false;
DefaultStyleKey = typeof(DockOverlay);
}
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
_border = GetTemplateChild("PART_Border") as Border;
UpdateVisual();
}
private static void OnVisualPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is DockOverlay overlay)
{
overlay.UpdateVisual();
}
}
private void UpdateVisual()
{
if (_border is null)
return;
Canvas.SetLeft(this, Bounds.X);
Canvas.SetTop(this, Bounds.Y);
Width = Bounds.Width;
Height = Bounds.Height;
_border.BorderBrush = new SolidColorBrush(Microsoft.UI.Colors.DeepSkyBlue);
_border.BorderThickness = new Thickness(2);
_border.Background = new SolidColorBrush(Microsoft.UI.Colors.LightSkyBlue) { Opacity = 0.25 };
}
}

View File

@@ -0,0 +1,47 @@
using Lattice.Layout.UI.Docking;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.Foundation;
namespace Lattice.Layout.UI.WinUI.Docking;
/// <summary>
/// Контейнер для отображения подсветки зон докинга.
/// Обычно используется как Overlay-слой внутри WinUILayoutHost.
/// </summary>
public sealed class DockOverlayHost : Canvas
{
private DockOverlay? _currentOverlay;
public DockOverlayHost()
{
IsHitTestVisible = false;
}
/// <summary>
/// Отображает подсветку для указанной цели докинга.
/// </summary>
public void ShowOverlay(DockTarget target, Rect bounds)
{
if (_currentOverlay is null)
{
_currentOverlay = new DockOverlay();
Children.Add(_currentOverlay);
}
_currentOverlay.Zone = target.Zone;
_currentOverlay.Bounds = bounds;
_currentOverlay.Visibility = Visibility.Visible;
}
/// <summary>
/// Скрывает подсветку зоны докинга.
/// </summary>
public void HideOverlay()
{
if (_currentOverlay is not null)
{
_currentOverlay.Visibility = Visibility.Collapsed;
}
}
}

View File

@@ -0,0 +1,78 @@
using Lattice.Layout.UI.Docking;
using Lattice.Layout.UI.WinUI.Controls;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using System;
using System.Linq;
using Windows.Foundation;
namespace Lattice.Layout.UI.WinUI.Docking;
/// <summary>
/// Выполняет hit-test для определения зоны докинга в WinUI.
/// </summary>
public static class DockZoneHitTester
{
/// <summary>
/// Выполняет hit-test по экранной точке и возвращает цель докинга.
/// </summary>
/// <param name="host">WinUI-хост раскладки.</param>
/// <param name="screenPoint">Точка в координатах окна.</param>
public static DockTarget? HitTest(WinUILayoutHost host, Point screenPoint)
{
if (host is null)
throw new ArgumentNullException(nameof(host));
// Предполагаем, что LayoutLayer — основной слой, в котором живёт визуальное дерево.
var layoutLayer = host.LayoutLayer;
if (layoutLayer is null)
return null;
// Переводим координаты в систему координат layoutLayer.
var elements = VisualTreeHelper.FindElementsInHostCoordinates(screenPoint, host.LayoutLayer);
var firstElement = elements.FirstOrDefault();
if (firstElement is null)
return null;
// Ищем ближайший IWinUIVisual.
var visual = FindVisual(firstElement);
if (visual is null)
return null;
if (visual is not ILayoutVisual layoutVisual)
return null;
// Вычисляем зону докинга для найденного контрола.
var control = visual.Control;
var bounds = control.TransformToVisual(layoutLayer)
.TransformBounds(new Rect(0, 0, control.ActualWidth, control.ActualHeight));
var localX = screenPoint.X - bounds.X;
var localY = screenPoint.Y - bounds.Y;
var zone = DockingUtils.GetZone(localX, localY, bounds.Width, bounds.Height);
return new DockTarget(layoutVisual, zone);
}
private static IWinUIVisual? FindVisual(UIElement element)
{
DependencyObject? current = element;
while (current is not null)
{
if (current is FrameworkElement fe && fe.DataContext is IWinUIVisual ctxVisual)
return ctxVisual;
if (current is IWinUIVisual winuiVisual)
return winuiVisual;
current = VisualTreeHelper.GetParent(current);
}
return null;
}
}

View File

@@ -0,0 +1,15 @@
using Microsoft.UI.Xaml;
namespace Lattice.Layout.UI.WinUI.Docking;
/// <summary>
/// Интерфейс для визуальных элементов WinUI, соответствующих элементам раскладки.
/// Нужен для hit-test и расчёта зон докинга.
/// </summary>
public interface IWinUIVisual
{
/// <summary>
/// Реальный WinUI-элемент, отображающий данный визуальный элемент раскладки.
/// </summary>
FrameworkElement Control { get; }
}

View File

@@ -0,0 +1,118 @@
using Lattice.Layout.Abstractions;
using Lattice.Layout.UI.WinUI.Controls;
using Microsoft.UI.Xaml;
using System;
namespace Lattice.Layout.UI.WinUI;
/// <summary>
/// Набор вспомогательных методов для упрощённой работы с визуальным хостом раскладки.
/// Позволяет быстро подключать LayoutManager, обновлять UI и связывать содержимое.
/// </summary>
public static class LayoutHostExtensions
{
// ------------------------------------------------------------------------
// 1. Подключение LayoutManager к любому ILayoutView
// ------------------------------------------------------------------------
/// <summary>
/// Подписывает визуальный хост на события изменения раскладки.
/// При каждом изменении модели вызывается <see cref="ILayoutView.Refresh"/>.
/// </summary>
/// <param name="view">Визуальный хост раскладки.</param>
/// <param name="manager">Менеджер раскладки.</param>
public static void BindToManager(this ILayoutView view, ILayoutManager manager)
{
manager.LayoutChanged += (_, _) => view.Refresh();
}
// ------------------------------------------------------------------------
// 2. Полная инициализация раскладки (Root + Manager)
// ------------------------------------------------------------------------
/// <summary>
/// Устанавливает корневой элемент раскладки, подключает менеджер и выполняет начальную отрисовку.
/// </summary>
/// <param name="view">Визуальный хост раскладки.</param>
/// <param name="root">Корневой элемент раскладки.</param>
/// <param name="manager">Менеджер раскладки.</param>
public static void UseLayout(this ILayoutView view, ILayoutRoot root, ILayoutManager manager)
{
view.Root = root;
view.BindToManager(manager);
view.Refresh();
}
// ------------------------------------------------------------------------
// 3. Удобный fluent-API
// ------------------------------------------------------------------------
/// <summary>
/// Устанавливает корневой элемент раскладки и возвращает хост для fluent-цепочек.
/// </summary>
public static T WithRoot<T>(this T view, ILayoutRoot root)
where T : ILayoutView
{
view.Root = root;
return view;
}
/// <summary>
/// Подключает менеджер раскладки и возвращает хост для fluent-цепочек.
/// </summary>
public static T WithManager<T>(this T view, ILayoutManager manager)
where T : ILayoutView
{
view.BindToManager(manager);
return view;
}
/// <summary>
/// Выполняет начальную отрисовку и возвращает хост для fluent-цепочек.
/// </summary>
public static T Initialize<T>(this T view)
where T : ILayoutView
{
view.Refresh();
return view;
}
// ------------------------------------------------------------------------
// 4. Поддержка WinUILayoutHost: резолвер контента
// ------------------------------------------------------------------------
/// <summary>
/// Устанавливает функцию, которая по ContentId возвращает реальный UI-элемент.
/// Используется для отображения содержимого вкладок.
/// </summary>
/// <param name="host">WinUI-хост раскладки.</param>
/// <param name="resolver">Функция, возвращающая UIElement по ContentId.</param>
public static void UseContentResolver(this WinUILayoutHost host, Func<string, UIElement> resolver)
{
host.ContentResolver = resolver;
}
// ------------------------------------------------------------------------
// 5. Полная WinUI-инициализация (Root + Manager + ContentResolver)
// ------------------------------------------------------------------------
/// <summary>
/// Полностью инициализирует WinUI-хост раскладки:
/// устанавливает корень, подключает менеджер, задаёт резолвер содержимого и выполняет отрисовку.
/// </summary>
/// <param name="host">WinUI-хост раскладки.</param>
/// <param name="root">Корневой элемент раскладки.</param>
/// <param name="manager">Менеджер раскладки.</param>
/// <param name="resolver">Функция получения UI-содержимого по ContentId.</param>
public static void UseLayout(
this WinUILayoutHost host,
ILayoutRoot root,
ILayoutManager manager,
Func<string, UIElement> resolver)
{
host.Root = root;
host.BindToManager(manager);
host.ContentResolver = resolver;
host.Refresh();
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0-windows10.0.19041.0;net9.0-windows10.0.19041.0;net10.0-windows10.0.19041.0</TargetFrameworks>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<RootNamespace>Lattice.Layout.UI.WinUI</RootNamespace>
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
<UseWinUI>true</UseWinUI>
<WinUISDKReferences>false</WinUISDKReferences>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.7463" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.251106002" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lattice.Layout.UI\Lattice.Layout.UI.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,30 @@
using Lattice.Layout.Abstractions;
using Lattice.Layout.UI.WinUI.Visuals;
using System.Collections.Generic;
namespace Lattice.Layout.UI.WinUI.Rendering;
/// <summary>
/// Фабрика визуальных элементов для WinUI.
/// Создаёт визуальные представления сплитов, групп и элементов.
/// </summary>
public sealed class WinUIVisualFactory : ILayoutVisualFactory
{
/// <inheritdoc />
public ILayoutVisual CreateSplit(ILayoutSplit split, IReadOnlyList<ILayoutVisual> children)
{
return new WinUISplitVisual(split, children);
}
/// <inheritdoc />
public ILayoutVisual CreateGroup(ILayoutGroup group, IReadOnlyList<ILayoutVisual> items)
{
return new WinUIGroupVisual(group, items);
}
/// <inheritdoc />
public ILayoutVisual CreateItem(ILayoutItem item)
{
return new WinUIItemVisual(item);
}
}

Some files were not shown because too many files have changed in this diff Show More