Skip to content

Commit d21bdc4

Browse files
authored
Merge branch 'main' into fix/parallels-vulkan-fallback
2 parents deec2bd + 6896f08 commit d21bdc4

11 files changed

Lines changed: 633 additions & 8 deletions

File tree

src/Avalonia.Base/Data/Core/BindingExpression.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,18 @@ internal void OnNodeValueChanged(int nodeIndex, object? value, Exception? dataVa
221221
var forceUpdate = _mode == BindingMode.OneWay || _updateTargetDepth > 0;
222222
ConvertAndPublishValue(value, error, forceUpdate);
223223
}
224+
else if (IsDataValidationEnabled)
225+
{
226+
// In OneWayToSource mode the value must not be published to the target, but any
227+
// data validation error produced when writing to the source still has to be
228+
// published (or cleared) so that it can be displayed (issue #8235). Publishing
229+
// UnchangedValue leaves the target's value untouched.
230+
var error = dataValidationError is not null ?
231+
new BindingError(dataValidationError, BindingErrorType.DataValidationError) :
232+
null;
233+
234+
PublishValue(UnchangedValue, error);
235+
}
224236
}
225237
else if (_mode == BindingMode.OneWayToSource && nodeIndex == _nodes.Count - 2 && value is not null)
226238
{

src/Avalonia.Base/Data/Core/ExpressionNodes/StreamNode.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Text;
33
using Avalonia.Data.Core.Plugins;
4+
using Avalonia.Reactive;
45

56
namespace Avalonia.Data.Core.ExpressionNodes;
67

@@ -30,7 +31,7 @@ protected override void OnSourceChanged(object? source, Exception? dataValidatio
3031

3132
if (_plugin.Start(new(source)) is { } accessor)
3233
{
33-
_subscription = accessor.Subscribe(this);
34+
_subscription = WeakObserverSubscription<object?>.Subscribe(accessor, this);
3435
}
3536
else
3637
{

src/Avalonia.Base/Data/Core/UntypedObservableBindingExpression.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using Avalonia.Reactive;
23

34
namespace Avalonia.Data.Core;
45

@@ -19,7 +20,7 @@ public UntypedObservableBindingExpression(
1920

2021
protected override void StartCore()
2122
{
22-
_subscription = _observable.Subscribe(this);
23+
_subscription = WeakObserverSubscription<object?>.Subscribe(_observable, this);
2324
}
2425

2526
protected override void StopCore()
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using System;
2+
using System.Diagnostics.CodeAnalysis;
3+
4+
namespace Avalonia.Reactive;
5+
6+
/// <summary>
7+
/// Subscribes an <see cref="IObserver{T}"/> to an <see cref="IObservable{T}"/> such that the
8+
/// observable holds only a weak reference to the observer, disposing the subscription once the
9+
/// observer has been collected.
10+
/// </summary>
11+
/// <typeparam name="T">The type of the elements in the sequence.</typeparam>
12+
internal sealed class WeakObserverSubscription<T> : IObserver<T>, IDisposable
13+
{
14+
private readonly WeakReference<IObserver<T>> _observer;
15+
private IDisposable? _subscription;
16+
17+
private WeakObserverSubscription(IObserver<T> observer)
18+
{
19+
_observer = new WeakReference<IObserver<T>>(observer);
20+
}
21+
22+
/// <summary>
23+
/// Subscribes <paramref name="observer"/> to <paramref name="observable"/> via a weak reference.
24+
/// </summary>
25+
/// <returns>
26+
/// A disposable which unsubscribes from the observable when disposed. The caller must keep it
27+
/// alive for as long as the subscription is required.
28+
/// </returns>
29+
public static IDisposable Subscribe(IObservable<T> observable, IObserver<T> observer)
30+
{
31+
var subscription = new WeakObserverSubscription<T>(observer);
32+
subscription._subscription = observable.Subscribe(subscription);
33+
return subscription;
34+
}
35+
36+
public void OnCompleted()
37+
{
38+
if (TryGetObserver(out var observer))
39+
observer.OnCompleted();
40+
}
41+
42+
public void OnError(Exception error)
43+
{
44+
if (TryGetObserver(out var observer))
45+
observer.OnError(error);
46+
}
47+
48+
public void OnNext(T value)
49+
{
50+
if (TryGetObserver(out var observer))
51+
observer.OnNext(value);
52+
}
53+
54+
public void Dispose()
55+
{
56+
_subscription?.Dispose();
57+
_subscription = null;
58+
}
59+
60+
private bool TryGetObserver([NotNullWhen(true)] out IObserver<T>? observer)
61+
{
62+
if (_observer.TryGetTarget(out observer))
63+
return true;
64+
65+
// The observer has been collected; unsubscribe from the observable.
66+
Dispose();
67+
return false;
68+
}
69+
}

src/Avalonia.Controls/SelectableTextBlock.cs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -539,9 +539,28 @@ private void OnTextOrInlinesChanged()
539539

540540
private void UpdateCommandStates()
541541
{
542-
var text = GetSelection();
542+
CanCopy = HasSelection();
543+
}
544+
545+
/// <summary>
546+
/// Reports the same emptiness conditions as <see cref="GetSelection"/>, without building
547+
/// the selected string.
548+
/// </summary>
549+
private bool HasSelection()
550+
{
551+
var selectionStart = SelectionStart;
552+
var selectionEnd = SelectionEnd;
553+
var start = Math.Min(selectionStart, selectionEnd);
554+
var end = Math.Max(selectionStart, selectionEnd);
555+
556+
if (start == end)
557+
{
558+
return false;
559+
}
560+
561+
var textLength = (HasComplexContent ? Inlines?.Text : Text)?.Length ?? 0;
543562

544-
CanCopy = !string.IsNullOrEmpty(text);
563+
return textLength > 0 && end <= textLength;
545564
}
546565

547566
private string GetSelection()

src/Avalonia.Controls/TextBox.cs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,10 +1112,9 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang
11121112

11131113
private void UpdateCommandStates()
11141114
{
1115-
var text = GetSelection();
1116-
var isSelectionNullOrEmpty = string.IsNullOrEmpty(text);
1117-
CanCopy = !IsPasswordBox && !isSelectionNullOrEmpty;
1118-
CanCut = !IsPasswordBox && !isSelectionNullOrEmpty && !IsReadOnly;
1115+
var hasSelection = HasSelection();
1116+
CanCopy = !IsPasswordBox && hasSelection;
1117+
CanCut = !IsPasswordBox && hasSelection && !IsReadOnly;
11191118
CanPaste = !IsReadOnly;
11201119
}
11211120

@@ -2450,6 +2449,24 @@ internal bool DeleteSelection()
24502449
return false;
24512450
}
24522451

2452+
/// <summary>
2453+
/// Reports the same emptiness conditions as <see cref="GetSelection"/>, without building
2454+
/// the selected string.
2455+
/// </summary>
2456+
private bool HasSelection()
2457+
{
2458+
var (start, end) = GetSelectionRange();
2459+
2460+
if (start == end)
2461+
{
2462+
return false;
2463+
}
2464+
2465+
var textLength = Text?.Length ?? 0;
2466+
2467+
return textLength > 0 && end <= textLength;
2468+
}
2469+
24532470
private string GetSelection()
24542471
{
24552472
var text = Text;

tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.DataValidation.cs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,102 @@ public void Indei_Validation_Updates_Data_Validation_When_Writing_To_Source()
271271
GC.KeepAlive(data);
272272
}
273273

274+
[Fact]
275+
public void Indei_Validation_Updates_Data_Validation_When_Writing_To_Source_OneWayToSource()
276+
{
277+
// Issue #8235: validation errors should be displayed for OneWayToSource bindings.
278+
var data = new IndeiViewModel();
279+
var target = CreateTargetWithSource(
280+
data,
281+
o => o.MustBePositive,
282+
enableDataValidation: true,
283+
mode: BindingMode.OneWayToSource);
284+
285+
Assert.Equal(0, data.MustBePositive);
286+
AssertNoError(target, TargetClass.IntProperty);
287+
288+
target.Int = 5;
289+
290+
Assert.Equal(5, data.MustBePositive);
291+
AssertNoError(target, TargetClass.IntProperty);
292+
293+
target.Int = -5;
294+
295+
Assert.Equal(-5, data.MustBePositive);
296+
AssertBindingError(target, TargetClass.IntProperty, new DataValidationException("Must be positive"), BindingErrorType.DataValidationError);
297+
298+
target.Int = 5;
299+
300+
Assert.Equal(5, data.MustBePositive);
301+
AssertNoError(target, TargetClass.IntProperty);
302+
303+
GC.KeepAlive(data);
304+
}
305+
306+
[Fact]
307+
public void DataAnnotations_Validation_Updates_Data_Validation_When_Writing_To_Source_OneWayToSource()
308+
{
309+
// Issue #8235: validation attributes should be displayed for OneWayToSource bindings.
310+
if (!BindingPlugins.DataValidators.Any(x => x is DataAnnotationsValidationPlugin))
311+
BindingPlugins.DataValidators.Insert(0, new DataAnnotationsValidationPlugin());
312+
313+
var data = new DataAnnotationsViewModel();
314+
var target = CreateTargetWithSource(
315+
data,
316+
o => o.MaxLengthString,
317+
enableDataValidation: true,
318+
mode: BindingMode.OneWayToSource);
319+
320+
target.String = "1234";
321+
322+
Assert.Equal("1234", data.MaxLengthString);
323+
AssertNoError(target, TargetClass.StringProperty);
324+
325+
target.String = "123456";
326+
327+
Assert.Equal("123456", data.MaxLengthString);
328+
AssertBindingError(
329+
target,
330+
TargetClass.StringProperty,
331+
new DataValidationException("Too long!"),
332+
BindingErrorType.DataValidationError);
333+
334+
GC.KeepAlive(data);
335+
}
336+
337+
[Fact]
338+
public void Conversion_Error_Is_Cleared_When_Value_Becomes_Valid_OneWayToSource()
339+
{
340+
// Issue #15378.
341+
var data = new ViewModel();
342+
var target = CreateTargetWithSource(
343+
data,
344+
o => o.DoubleValue,
345+
targetProperty: TargetClass.ObjectProperty,
346+
enableDataValidation: true,
347+
mode: BindingMode.OneWayToSource);
348+
349+
target.Object = 5.0;
350+
351+
Assert.Equal(5.0, data.DoubleValue);
352+
AssertNoError(target, TargetClass.ObjectProperty);
353+
354+
target.Object = null;
355+
356+
AssertBindingError(
357+
target,
358+
TargetClass.ObjectProperty,
359+
new InvalidCastException("Could not convert '(null)' (null) to System.Double."),
360+
BindingErrorType.DataValidationError);
361+
362+
target.Object = 5.0;
363+
364+
Assert.Equal(5.0, data.DoubleValue);
365+
AssertNoError(target, TargetClass.ObjectProperty);
366+
367+
GC.KeepAlive(data);
368+
}
369+
274370
[Fact]
275371
public void Does_Not_Subscribe_To_Indei_Of_Intermediate_Object_In_Chain()
276372
{
@@ -445,6 +541,15 @@ public string? RequiredString
445541
get { return _requiredString; }
446542
set { _requiredString = value; RaisePropertyChanged(); }
447543
}
544+
545+
private string? _maxLengthString;
546+
547+
[MaxLength(5, ErrorMessage = "Too long!")]
548+
public string? MaxLengthString
549+
{
550+
get { return _maxLengthString; }
551+
set { _maxLengthString = value; RaisePropertyChanged(); }
552+
}
448553
}
449554

450555
private class IndeiDataAnnotationsViewModel : IndeiBase

0 commit comments

Comments
 (0)