diff --git a/src/Dock.Avalonia.Themes.Browser/Styles/BrowserTabAccents.axaml b/src/Dock.Avalonia.Themes.Browser/Styles/BrowserTabAccents.axaml index e0efaeee7..f5e6f9b3f 100644 --- a/src/Dock.Avalonia.Themes.Browser/Styles/BrowserTabAccents.axaml +++ b/src/Dock.Avalonia.Themes.Browser/Styles/BrowserTabAccents.axaml @@ -2,7 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:sys="clr-namespace:System;assembly=netstandard" xmlns:platform="clr-namespace:Avalonia.Platform;assembly=Avalonia.Controls" - xmlns:core="using:Dock.Model.Core"> + xmlns:core="using:Dock.Model.Core" + xmlns:controls="using:Dock.Avalonia.Controls"> @@ -12,10 +13,12 @@ #D7DFEC #2F6FDC + #CBD9F2 + #CC000000 #4D87E7 #2F6FDC @@ -60,10 +63,12 @@ #334055 #5C9BFF + #415271 + #CCFFFFFF #4C89E8 #5C9BFF @@ -112,6 +117,8 @@ 24 28 + 28 + 0.55 0 28 0 - + + diff --git a/src/Dock.Avalonia.Themes.Browser/Styles/BrowserTabTheme.axaml b/src/Dock.Avalonia.Themes.Browser/Styles/BrowserTabTheme.axaml index dac38aa91..44d6d9369 100644 --- a/src/Dock.Avalonia.Themes.Browser/Styles/BrowserTabTheme.axaml +++ b/src/Dock.Avalonia.Themes.Browser/Styles/BrowserTabTheme.axaml @@ -14,4 +14,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Dock.Avalonia.Themes.Fluent/Accents/Fluent.axaml b/src/Dock.Avalonia.Themes.Fluent/Accents/Fluent.axaml index e462d6e34..9a2cd86d2 100644 --- a/src/Dock.Avalonia.Themes.Fluent/Accents/Fluent.axaml +++ b/src/Dock.Avalonia.Themes.Fluent/Accents/Fluent.axaml @@ -60,9 +60,11 @@ 24 24 + 64 0 0 0 + 0 0 4,0,4,0 0 diff --git a/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentTabStrip.axaml b/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentTabStrip.axaml index 9da648d03..fadeaf2b8 100644 --- a/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentTabStrip.axaml +++ b/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentTabStrip.axaml @@ -97,7 +97,8 @@ - @@ -124,6 +125,7 @@ + diff --git a/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentTabStripItem.axaml b/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentTabStripItem.axaml index 3297cbedb..ef5d6cc1e 100644 --- a/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentTabStripItem.axaml +++ b/src/Dock.Avalonia.Themes.Fluent/Controls/DocumentTabStripItem.axaml @@ -314,6 +314,7 @@ + @@ -326,44 +327,72 @@ - - - - - - - + + + + + + + + + + + + + + - - - - - - - + + + + + @@ -402,6 +431,27 @@ + + + + + + + + + + diff --git a/src/Dock.Avalonia/Controls/DelayedUniformTabPanel.cs b/src/Dock.Avalonia/Controls/DelayedUniformTabPanel.cs new file mode 100644 index 000000000..fa463002a --- /dev/null +++ b/src/Dock.Avalonia/Controls/DelayedUniformTabPanel.cs @@ -0,0 +1,592 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Reactive; +using Avalonia.Threading; +using Avalonia.VisualTree; + +namespace Dock.Avalonia.Controls; + +/// +/// Arranges visible children in a single horizontal row with a uniform width. +/// Shrinks immediately when constrained and expands after a delay. +/// +public class DelayedUniformTabPanel : Panel +{ + private const double WidthEpsilon = 0.1; + private readonly DispatcherTimer _expansionTimer; + private readonly List _layoutSubscriptions = new(); + private double _currentTabWidth = double.NaN; + private double _pendingExpansionWidth = double.NaN; + private int _lastVisibleCount = -1; + private bool _resolvedInMeasurePass; + private bool _isRemovalDebounceActive; + + /// + /// Defines the property. + /// + public static readonly StyledProperty MaxTabWidthProperty = + AvaloniaProperty.Register(nameof(MaxTabWidth), 220d); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MinTabWidthProperty = + AvaloniaProperty.Register(nameof(MinTabWidth), 80d); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ItemSpacingProperty = + AvaloniaProperty.Register(nameof(ItemSpacing), 0d); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ExpansionDelayProperty = + AvaloniaProperty.Register(nameof(ExpansionDelay), TimeSpan.FromSeconds(1)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ResizeAnimationDurationProperty = + AvaloniaProperty.Register(nameof(ResizeAnimationDuration), TimeSpan.Zero); + + /// + /// Defines the animated tab width property. + /// + public static readonly StyledProperty AnimatedTabWidthProperty = + AvaloniaProperty.Register(nameof(AnimatedTabWidth), double.NaN); + + static DelayedUniformTabPanel() + { + AffectsMeasure(MaxTabWidthProperty, MinTabWidthProperty, ItemSpacingProperty, AnimatedTabWidthProperty); + AffectsArrange(MaxTabWidthProperty, MinTabWidthProperty, ItemSpacingProperty, AnimatedTabWidthProperty); + } + + /// + /// Initializes a new instance of the class. + /// + public DelayedUniformTabPanel() + { + _expansionTimer = new DispatcherTimer { IsEnabled = false }; + _expansionTimer.Tick += OnExpansionTimerTick; + UpdateResizeTransition(); + } + + /// + /// Gets or sets the maximum width used for each tab when enough space is available. + /// + public double MaxTabWidth + { + get => GetValue(MaxTabWidthProperty); + set => SetValue(MaxTabWidthProperty, value); + } + + /// + /// Gets or sets the minimum width used for each tab before overflow occurs. + /// + public double MinTabWidth + { + get => GetValue(MinTabWidthProperty); + set => SetValue(MinTabWidthProperty, value); + } + + /// + /// Gets or sets spacing between tabs. + /// + public double ItemSpacing + { + get => GetValue(ItemSpacingProperty); + set => SetValue(ItemSpacingProperty, value); + } + + /// + /// Gets or sets the delay applied before expanding tab widths. + /// + public TimeSpan ExpansionDelay + { + get => GetValue(ExpansionDelayProperty); + set => SetValue(ExpansionDelayProperty, value); + } + + /// + /// Gets or sets animation duration for tab width changes. + /// + public TimeSpan ResizeAnimationDuration + { + get => GetValue(ResizeAnimationDurationProperty); + set => SetValue(ResizeAnimationDurationProperty, value); + } + + private double AnimatedTabWidth + { + get => GetValue(AnimatedTabWidthProperty); + set => SetValue(AnimatedTabWidthProperty, value); + } + + /// + protected override Size MeasureOverride(Size availableSize) + { + var visibleCount = 0; + foreach (var child in Children) + { + if (child.IsVisible) + { + visibleCount++; + } + } + + if (visibleCount == 0) + { + _currentTabWidth = double.NaN; + _lastVisibleCount = -1; + AnimatedTabWidth = double.NaN; + _resolvedInMeasurePass = false; + _isRemovalDebounceActive = false; + StopExpansionTimer(); + return new Size(0d, 0d); + } + + var targetTabWidth = ComputeTargetTabWidth(visibleCount, availableSize); + var tabWidth = ResolveTabWidth(visibleCount, availableSize); + _resolvedInMeasurePass = true; + var spacing = Math.Max(0d, ItemSpacing); + var maxHeight = 0d; + + foreach (var child in Children) + { + if (!child.IsVisible) + { + child.Measure(default); + continue; + } + + var measureHeight = IsFinite(availableSize.Height) ? availableSize.Height : double.PositiveInfinity; + child.Measure(new Size(tabWidth, measureHeight)); + maxHeight = Math.Max(maxHeight, child.DesiredSize.Height); + } + + // During close-burst debounce, keep extent aligned with current compact width so + // the create button stays visually attached to the last tab. + var extentTabWidth = _isRemovalDebounceActive + ? tabWidth + : (IsFinite(targetTabWidth) ? targetTabWidth : tabWidth); + var desiredWidth = (extentTabWidth * visibleCount) + (spacing * Math.Max(0, visibleCount - 1)); + return new Size(desiredWidth, maxHeight); + } + + /// + protected override Size ArrangeOverride(Size finalSize) + { + var visibleCount = 0; + foreach (var child in Children) + { + if (child.IsVisible) + { + visibleCount++; + } + } + + if (visibleCount == 0) + { + _resolvedInMeasurePass = false; + return finalSize; + } + + var tabWidth = _resolvedInMeasurePass + ? (IsFinite(_currentTabWidth) ? _currentTabWidth : ResolveTabWidth(visibleCount, finalSize)) + : ResolveTabWidth(visibleCount, finalSize); + _resolvedInMeasurePass = false; + var spacing = Math.Max(0d, ItemSpacing); + var x = 0d; + + foreach (var child in Children) + { + if (!child.IsVisible) + { + child.Arrange(default); + continue; + } + + child.Arrange(new Rect(x, 0d, tabWidth, finalSize.Height)); + x += tabWidth + spacing; + } + + return finalSize; + } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ExpansionDelayProperty && _expansionTimer.IsEnabled) + { + RestartExpansionTimer(); + } + + if (change.Property == ResizeAnimationDurationProperty) + { + UpdateResizeTransition(); + } + + if (change.Property == AnimatedTabWidthProperty) + { + var animatedWidth = change.GetNewValue(); + if (IsFinite(animatedWidth)) + { + _currentTabWidth = animatedWidth; + } + } + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + StopExpansionTimer(); + ClearLayoutObservers(); + } + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + AttachLayoutObservers(); + InvalidateMeasure(); + } + + private static bool IsFinite(double value) + { + return !double.IsNaN(value) && !double.IsInfinity(value); + } + + private static bool NearlyEqual(double left, double right) + { + return Math.Abs(left - right) <= WidthEpsilon; + } + + private double ResolveTabWidth(int visibleCount, Size referenceSize) + { + var targetWidth = ComputeTargetTabWidth(visibleCount, referenceSize); + var previousVisibleCount = _lastVisibleCount; + var countChanged = visibleCount != previousVisibleCount; + var countDecreased = previousVisibleCount >= 0 && visibleCount < previousVisibleCount; + _lastVisibleCount = visibleCount; + + if (!IsFinite(_currentTabWidth)) + { + _currentTabWidth = targetWidth; + _pendingExpansionWidth = double.NaN; + AnimatedTabWidth = targetWidth; + _isRemovalDebounceActive = false; + return _currentTabWidth; + } + + if (countDecreased) + { + _isRemovalDebounceActive = true; + QueueExpansion(targetWidth); + return _currentTabWidth; + } + + if (targetWidth < _currentTabWidth - WidthEpsilon) + { + StopExpansionTimer(); + _pendingExpansionWidth = double.NaN; + _isRemovalDebounceActive = false; + AnimateToTabWidth(targetWidth); + return _currentTabWidth; + } + + if (targetWidth > _currentTabWidth + WidthEpsilon) + { + if (countChanged || _isRemovalDebounceActive) + { + QueueExpansion(targetWidth); + } + else + { + StopExpansionTimer(); + _pendingExpansionWidth = double.NaN; + _isRemovalDebounceActive = false; + AnimateToTabWidth(targetWidth); + } + + return _currentTabWidth; + } + + return _currentTabWidth; + } + + private double ComputeTargetTabWidth(int visibleCount, Size referenceSize) + { + var maxTabWidth = Math.Max(0d, MaxTabWidth); + var minimumTabWidth = Math.Max(0d, MinTabWidth); + var minimumFromChildren = GetChildMinimumTabWidth(); + var effectiveMinWidth = Math.Max(minimumTabWidth, minimumFromChildren); + var spacing = Math.Max(0d, ItemSpacing); + var viewportWidth = ResolveViewportWidth(referenceSize); + + if (visibleCount <= 0) + { + return maxTabWidth; + } + + if (!IsFinite(viewportWidth)) + { + return Math.Max(effectiveMinWidth, maxTabWidth); + } + + var availableForTabs = Math.Max(0d, viewportWidth - (spacing * Math.Max(0, visibleCount - 1))); + var perTab = availableForTabs / visibleCount; + var target = Math.Min(maxTabWidth, perTab); + return Math.Max(effectiveMinWidth, target); + } + + private double ResolveViewportWidth(Size referenceSize) + { + if (TryGetDockPanelAvailableWidth(out var dockPanelAvailableWidth)) + { + return dockPanelAvailableWidth; + } + + if (this.FindAncestorOfType() is { } scrollViewer && + IsFinite(scrollViewer.Bounds.Width) && + scrollViewer.Bounds.Width > 0d) + { + return scrollViewer.Bounds.Width; + } + + if (IsFinite(referenceSize.Width) && referenceSize.Width > 0d) + { + return referenceSize.Width; + } + + if (IsFinite(Bounds.Width) && Bounds.Width > 0d) + { + return Bounds.Width; + } + + return double.NaN; + } + + private bool TryGetDockPanelAvailableWidth(out double width) + { + width = double.NaN; + + var partPanel = FindAncestorNamed("PART_Panel"); + if (partPanel?.Parent is not DockPanel dockPanel) + { + return false; + } + + if (!IsFinite(dockPanel.Bounds.Width) || dockPanel.Bounds.Width <= 0d) + { + return false; + } + + var fixedSiblingsWidth = 0d; + foreach (var child in dockPanel.Children) + { + if (!child.IsVisible || ReferenceEquals(child, partPanel)) + { + continue; + } + + if (child.Name is "PART_TrailingFill") + { + continue; + } + + var boundsWidth = IsFinite(child.Bounds.Width) ? child.Bounds.Width : 0d; + var desiredWidth = IsFinite(child.DesiredSize.Width) ? child.DesiredSize.Width : 0d; + var reservedWidth = Math.Max(boundsWidth, desiredWidth); + fixedSiblingsWidth += reservedWidth; + fixedSiblingsWidth += child.Margin.Left + child.Margin.Right; + } + + width = Math.Max(0d, dockPanel.Bounds.Width - fixedSiblingsWidth); + return width > 0d; + } + + private void AttachLayoutObservers() + { + ClearLayoutObservers(); + + var dockPanel = FindAncestorOfType(); + if (dockPanel is not null) + { + _layoutSubscriptions.Add( + dockPanel.GetObservable(BoundsProperty) + .Subscribe(new AnonymousObserver(_ => InvalidateMeasure()))); + + foreach (var child in dockPanel.Children) + { + _layoutSubscriptions.Add( + child.GetObservable(BoundsProperty) + .Subscribe(new AnonymousObserver(_ => InvalidateMeasure()))); + _layoutSubscriptions.Add( + child.GetObservable(IsVisibleProperty) + .Subscribe(new AnonymousObserver(_ => InvalidateMeasure()))); + } + } + } + + private void ClearLayoutObservers() + { + foreach (var disposable in _layoutSubscriptions) + { + disposable.Dispose(); + } + + _layoutSubscriptions.Clear(); + } + + private T? FindAncestorNamed(string name) + where T : StyledElement + { + for (var current = this.GetVisualParent(); current is not null; current = current.GetVisualParent()) + { + if (current is T typed && string.Equals(typed.Name, name, StringComparison.Ordinal)) + { + return typed; + } + } + + return null; + } + + private T? FindAncestorOfType() + where T : StyledElement + { + for (var current = this.GetVisualParent(); current is not null; current = current.GetVisualParent()) + { + if (current is T typed) + { + return typed; + } + } + + return null; + } + + private double GetChildMinimumTabWidth() + { + var minWidth = 0d; + + foreach (var child in Children) + { + if (!child.IsVisible) + { + continue; + } + + minWidth = Math.Max(minWidth, child.MinWidth); + } + + return minWidth; + } + + private void QueueExpansion(double targetWidth) + { + var delay = ExpansionDelay; + if (delay <= TimeSpan.Zero) + { + StopExpansionTimer(); + _pendingExpansionWidth = double.NaN; + _isRemovalDebounceActive = false; + AnimateToTabWidth(targetWidth); + return; + } + + _pendingExpansionWidth = targetWidth; + RestartExpansionTimer(); + } + + private void RestartExpansionTimer() + { + _expansionTimer.Stop(); + _expansionTimer.Interval = ExpansionDelay <= TimeSpan.Zero ? TimeSpan.Zero : ExpansionDelay; + _expansionTimer.Start(); + } + + private void StopExpansionTimer() + { + _expansionTimer.Stop(); + } + + private void OnExpansionTimerTick(object? sender, EventArgs e) + { + _expansionTimer.Stop(); + + if (!IsFinite(_pendingExpansionWidth)) + { + _isRemovalDebounceActive = false; + return; + } + + AnimateToTabWidth(_pendingExpansionWidth); + _pendingExpansionWidth = double.NaN; + _isRemovalDebounceActive = false; + } + + private void AnimateToTabWidth(double targetWidth) + { + if (!IsFinite(targetWidth)) + { + return; + } + + if (!IsFinite(_currentTabWidth)) + { + _currentTabWidth = targetWidth; + AnimatedTabWidth = targetWidth; + return; + } + + if (NearlyEqual(_currentTabWidth, targetWidth)) + { + _currentTabWidth = targetWidth; + AnimatedTabWidth = targetWidth; + return; + } + + var duration = ResizeAnimationDuration; + if (duration <= TimeSpan.Zero) + { + _currentTabWidth = targetWidth; + AnimatedTabWidth = targetWidth; + } + else + { + AnimatedTabWidth = targetWidth; + } + } + + private void UpdateResizeTransition() + { + if (ResizeAnimationDuration <= TimeSpan.Zero) + { + Transitions = null; + return; + } + + Transitions = + [ + new DoubleTransition + { + Property = AnimatedTabWidthProperty, + Duration = ResizeAnimationDuration, + Easing = new CubicEaseOut() + } + ]; + } +} diff --git a/src/Dock.Avalonia/Controls/DocumentTabStrip.axaml.cs b/src/Dock.Avalonia/Controls/DocumentTabStrip.axaml.cs index 542e3c6e7..bba55f71d 100644 --- a/src/Dock.Avalonia/Controls/DocumentTabStrip.axaml.cs +++ b/src/Dock.Avalonia/Controls/DocumentTabStrip.axaml.cs @@ -1,6 +1,7 @@ // Copyright (c) Wiesław Šoltés. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. using System; +using System.Collections.Generic; using Avalonia; using Avalonia.Automation.Peers; using Avalonia.Controls; @@ -11,6 +12,7 @@ using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; +using Avalonia.Reactive; using Avalonia.Styling; using Dock.Avalonia.Automation.Peers; @@ -24,7 +26,11 @@ public class DocumentTabStrip : TabStrip { private HostWindow? _attachedWindow; private Control? _grip; + private Control? _panel; + private Control? _leadingSpacer; + private Control? _createButtonHost; private ScrollViewer? _scrollViewer; + private readonly List _templateObservables = new(); private IDisposable? _scrollViewerWheelSubscription; private IDisposable? _doubleTappedSubscription; private WindowDragHelper? _windowDragHelper; @@ -295,11 +301,16 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) base.OnApplyTemplate(e); DetachScrollViewerWheel(); + ClearTemplateObservables(); _grip = e.NameScope.Find("PART_BorderFill"); + _panel = e.NameScope.Find("PART_Panel"); + _leadingSpacer = e.NameScope.Find("PART_LeadingSpacer"); + _createButtonHost = e.NameScope.Find("PART_CreateButtonHost"); _scrollViewer = e.NameScope.Find("PART_ScrollViewer"); AttachDoubleTapped(); AttachScrollViewerWheel(); + AttachTemplateObservables(); AttachToWindow(); } @@ -319,6 +330,7 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e base.OnDetachedFromVisualTree(e); DetachDoubleTapped(); DetachScrollViewerWheel(); + ClearTemplateObservables(); DetachFromWindow(); } @@ -380,6 +392,11 @@ private void OnDoubleTapped(object? sender, TappedEventArgs e) return; } + if (ShouldIgnoreWindowStateToggleSource(e.Source)) + { + return; + } + if (TopLevel.GetTopLevel(this) is not Window window) { return; @@ -395,6 +412,89 @@ private void OnDoubleTapped(object? sender, TappedEventArgs e) e.Handled = true; } + internal bool ShouldIgnoreWindowStateToggleSource(object? source) + { + if (source is not Control control) + { + return false; + } + + return control is Button || WindowDragHelper.IsChildOfType