Skip to content

Commit 6df2052

Browse files
committed
Improve style environment binding and invalidation
1 parent bfc9480 commit 6df2052

File tree

6 files changed

+319
-2
lines changed

6 files changed

+319
-2
lines changed

site/docs/binding.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,17 @@ Use `Func<T>` to compute a value on demand, while still being dependency-tracked
139139
new TextBlock(() => $"Tick: {tick.Value}")
140140
```
141141

142+
The same pattern applies to styles:
143+
144+
```csharp
145+
new Button("Save")
146+
.Style(() => isDanger.Value
147+
? (ButtonStyle.Default with { ShowBorder = true })
148+
: ButtonStyle.Default);
149+
```
150+
151+
For style-specific guidance, see [Styling](styling.md).
152+
142153
## Two-way binding
143154

144155
Some controls (TextBox/TextArea) can bind their value to a `State<string>` by providing a document wrapper that reads/writes the bound value.

site/docs/styling.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,29 @@ Controls obtain their styles from the environment:
4949
new Button("OK").Style(new ButtonStyle { Tone = ControlTone.Primary })
5050
```
5151

52+
Styles can also be resolved dynamically from a factory (dependency-tracked):
53+
54+
```csharp
55+
var danger = new State<bool>(false);
56+
57+
new Button("Deploy")
58+
.Style(() => danger.Value
59+
? (ButtonStyle.Default with { ShowBorder = true })
60+
: ButtonStyle.Default);
61+
```
62+
63+
And styles can come from a binding:
64+
65+
```csharp
66+
var buttonStyle = new State<ButtonStyle>(ButtonStyle.Default);
67+
68+
new Button("Apply").Style(buttonStyle);
69+
```
70+
71+
> [!NOTE]
72+
> Style resolution follows normal environment lookup rules: nearest visual with a local value wins.
73+
> Values set by `Style(...)`, `SetStyle(...)`, factories, and bindings all participate in this lookup.
74+
5275
Styles are records, so variations can be created with `with`:
5376

5477
```csharp

src/XenoAtom.Terminal.UI.Tests/ComputedVisualTests.cs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,5 +60,91 @@ public void EnvironmentValue_Invalidates_ComputedVisual()
6060
var rendered = screen.GetText();
6161
StringAssert.Contains(rendered, "Env:B");
6262
}
63+
64+
[TestMethod]
65+
public void EnvironmentValue_Invalidates_When_IntermediateParent_Overrides_Source()
66+
{
67+
var key = new StyleKey<string>("Title", "Default");
68+
69+
ComputedVisual? view = null;
70+
view = new ComputedVisual(() => new TextBlock($"Env:{view!.GetStyle(key)}"));
71+
72+
var root = new VStack();
73+
var parent = new VStack();
74+
root.Add(parent);
75+
parent.Add(view);
76+
77+
root.SetStyle(key, "Root");
78+
79+
using var driver = new TerminalAppTestDriver(root, TerminalHostKind.Inline, new TerminalSize(40, 10));
80+
driver.Tick();
81+
82+
parent.SetStyle(key, "Parent");
83+
driver.Tick();
84+
85+
var outText = driver.Backend.GetOutText();
86+
StringAssert.Contains(outText, "Env:Root");
87+
88+
var screen = new AnsiTestScreen(40, 10);
89+
screen.Apply(outText);
90+
var rendered = screen.GetText();
91+
StringAssert.Contains(rendered, "Env:Parent");
92+
}
93+
94+
[TestMethod]
95+
public void EnvironmentValue_Invalidates_ComputedVisual_From_StyleBinding()
96+
{
97+
var key = new StyleKey<string>("Title", "Default");
98+
var state = new State<string>("A");
99+
100+
ComputedVisual? view = null;
101+
view = new ComputedVisual(() => new TextBlock($"Env:{view!.GetStyle(key)}"));
102+
103+
var root = new VStack();
104+
root.Add(view);
105+
root.SetStyle(key, (Binding<string>)state);
106+
107+
using var driver = new TerminalAppTestDriver(root, TerminalHostKind.Inline, new TerminalSize(40, 10));
108+
driver.Tick();
109+
110+
state.Value = "B";
111+
driver.Tick();
112+
113+
var outText = driver.Backend.GetOutText();
114+
StringAssert.Contains(outText, "Env:A");
115+
116+
var screen = new AnsiTestScreen(40, 10);
117+
screen.Apply(outText);
118+
var rendered = screen.GetText();
119+
StringAssert.Contains(rendered, "Env:B");
120+
}
121+
122+
[TestMethod]
123+
public void EnvironmentValue_Invalidates_ComputedVisual_From_StyleFactory()
124+
{
125+
var key = new StyleKey<string>("Title", "Default");
126+
var state = new State<string>("A");
127+
128+
ComputedVisual? view = null;
129+
view = new ComputedVisual(() => new TextBlock($"Env:{view!.GetStyle(key)}"));
130+
131+
var root = new VStack();
132+
root.Add(view);
133+
root.SetStyle(key, () => state.Value + "_factory");
134+
135+
using var driver = new TerminalAppTestDriver(root, TerminalHostKind.Inline, new TerminalSize(40, 10));
136+
driver.Tick();
137+
138+
state.Value = "B";
139+
driver.Tick();
140+
141+
var outText = driver.Backend.GetOutText();
142+
StringAssert.Contains(outText, "Env:A_factory");
143+
144+
var screen = new AnsiTestScreen(40, 10);
145+
screen.Apply(outText);
146+
var rendered = screen.GetText();
147+
StringAssert.Contains(rendered, "Env:B_factory");
148+
}
63149
}
64150

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) Alexandre Mutel. All rights reserved.
2+
// Licensed under the BSD-Clause 2 license.
3+
// See license.txt file in the project root for full license information.
4+
5+
using XenoAtom.Terminal.UI.Controls;
6+
using XenoAtom.Terminal.UI.Styling;
7+
8+
namespace XenoAtom.Terminal.UI.Tests;
9+
10+
[TestClass]
11+
public sealed class StyleFluentSyntaxTests
12+
{
13+
[TestMethod]
14+
public void Style_Fluent_Accepts_Direct_Style_Value()
15+
{
16+
var button = new Button("Apply")
17+
.Style(ButtonStyle.Default with { ShowBorder = true });
18+
19+
Assert.IsTrue(button.GetStyle<ButtonStyle>().ShowBorder);
20+
Assert.IsTrue(button.HasLocalStyle(ButtonStyle.Key));
21+
}
22+
23+
[TestMethod]
24+
public void Style_Fluent_Accepts_Style_Factory()
25+
{
26+
var isDanger = new State<bool>(false);
27+
var button = new Button("Deploy")
28+
.Style(() => isDanger.Value
29+
? (ButtonStyle.Default with { ShowBorder = true })
30+
: ButtonStyle.Default);
31+
32+
Assert.IsFalse(button.GetStyle<ButtonStyle>().ShowBorder);
33+
34+
isDanger.Value = true;
35+
Assert.IsTrue(button.GetStyle<ButtonStyle>().ShowBorder);
36+
}
37+
38+
[TestMethod]
39+
public void Style_Fluent_Accepts_State_Implicit_Binding()
40+
{
41+
var styleState = new State<ButtonStyle>(ButtonStyle.Default);
42+
var button = new Button("Apply").Style(styleState);
43+
44+
Assert.IsFalse(button.GetStyle<ButtonStyle>().ShowBorder);
45+
46+
styleState.Value = ButtonStyle.Default with { ShowBorder = true };
47+
Assert.IsTrue(button.GetStyle<ButtonStyle>().ShowBorder);
48+
}
49+
}

src/XenoAtom.Terminal.UI/Visual.cs

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,20 @@ protected void DetachChild(Visual child)
434434
/// <param name="value">The style value.</param>
435435
public void SetStyle<T>(T value) where T : IStyle<T> => SetStyle(T.Key, value);
436436

437+
/// <summary>
438+
/// Sets a style value factory in the environment of this visual and returns it by type.
439+
/// </summary>
440+
/// <typeparam name="T">The style type.</typeparam>
441+
/// <param name="value">The factory used to resolve the style value.</param>
442+
public void SetStyle<T>(Func<T> value) where T : IStyle<T> => SetStyle(T.Key, value);
443+
444+
/// <summary>
445+
/// Sets a style binding in the environment of this visual and returns it by type.
446+
/// </summary>
447+
/// <typeparam name="T">The style type.</typeparam>
448+
/// <param name="value">The binding used to resolve the style value.</param>
449+
public void SetStyle<T>(Binding<T> value) where T : IStyle<T> => SetStyle(T.Key, value);
450+
437451
/// <summary>
438452
/// Sets a style value in the environment of this visual.
439453
/// </summary>
@@ -444,9 +458,51 @@ public void SetStyle<T>(StyleKey<T> key, T value)
444458
{
445459
VerifyAccess();
446460
ArgumentNullException.ThrowIfNull(key);
461+
462+
var oldSource = ResolveStyleSourceBeforeSet(key);
463+
447464
StyleEnvironment ??= new Dictionary<object, object?>();
448465
StyleEnvironment[key] = value;
449-
BindingManager.Current.NotifyValueChanged(this, key.BindingAccessor);
466+
467+
NotifyStyleSourceChange(key, oldSource);
468+
}
469+
470+
/// <summary>
471+
/// Sets a style value factory in the environment of this visual.
472+
/// </summary>
473+
/// <typeparam name="T">The style type.</typeparam>
474+
/// <param name="key">The style key.</param>
475+
/// <param name="value">The factory used to resolve the style value.</param>
476+
public void SetStyle<T>(StyleKey<T> key, Func<T> value)
477+
{
478+
VerifyAccess();
479+
ArgumentNullException.ThrowIfNull(key);
480+
ArgumentNullException.ThrowIfNull(value);
481+
482+
var oldSource = ResolveStyleSourceBeforeSet(key);
483+
484+
StyleEnvironment ??= new Dictionary<object, object?>();
485+
StyleEnvironment[key] = value;
486+
487+
NotifyStyleSourceChange(key, oldSource);
488+
}
489+
490+
/// <summary>
491+
/// Sets a style binding in the environment of this visual.
492+
/// </summary>
493+
/// <typeparam name="T">The style type.</typeparam>
494+
/// <param name="key">The style key.</param>
495+
/// <param name="value">The binding used to resolve the style value.</param>
496+
public void SetStyle<T>(StyleKey<T> key, Binding<T> value)
497+
{
498+
VerifyAccess();
499+
ArgumentNullException.ThrowIfNull(key);
500+
if (value.IsEmpty)
501+
{
502+
throw new ArgumentException("The binding cannot be empty.", nameof(value));
503+
}
504+
505+
SetStyle(key, value.GetValue);
450506
}
451507

452508
/// <summary>
@@ -475,7 +531,7 @@ public T GetStyle<T>(StyleKey<T> key)
475531
if (v.StyleEnvironment is not null && v.StyleEnvironment.TryGetValue(key, out var boxed))
476532
{
477533
BindingManager.Current.RegisterRead(v, key.BindingAccessor);
478-
return boxed is T typed ? typed : key.DefaultValue;
534+
return ResolveStyleValue(key, boxed);
479535
}
480536
}
481537

@@ -501,6 +557,47 @@ public bool HasLocalStyle<T>(StyleKey<T> key)
501557
/// </summary>
502558
public Theme GetTheme() => GetStyle<Theme>();
503559

560+
private void NotifyStyleSourceChange<T>(StyleKey<T> key, Visual oldSource)
561+
{
562+
if (!ReferenceEquals(oldSource, this))
563+
{
564+
BindingManager.Current.NotifyValueChanged(oldSource, key.BindingAccessor);
565+
}
566+
567+
BindingManager.Current.NotifyValueChanged(this, key.BindingAccessor);
568+
}
569+
570+
private Visual ResolveStyleSourceBeforeSet<T>(StyleKey<T> key)
571+
{
572+
Visual? root = null;
573+
for (var v = this; v is not null; v = v.Parent)
574+
{
575+
root = v;
576+
if (v.StyleEnvironment is not null && v.StyleEnvironment.ContainsKey(key))
577+
{
578+
return v;
579+
}
580+
}
581+
582+
return root ?? this;
583+
}
584+
585+
private static T ResolveStyleValue<T>(StyleKey<T> key, object? boxed)
586+
{
587+
if (boxed is T typed)
588+
{
589+
return typed;
590+
}
591+
592+
if (boxed is Func<T> factory)
593+
{
594+
var resolved = factory();
595+
return resolved is null ? key.DefaultValue : resolved;
596+
}
597+
598+
return key.DefaultValue;
599+
}
600+
504601
/// <summary>
505602
/// Gets the absolute bounds of this visual in the coordinate space of the visual tree root.
506603
/// </summary>

src/XenoAtom.Terminal.UI/VisualExtensions.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,4 +208,55 @@ public static T Style<T, TStyle>(this T obj, TStyle style) where T : Visual wher
208208
obj.SetStyle(style);
209209
return obj;
210210
}
211+
212+
/// <summary>
213+
/// Applies a style factory to a visual by storing it in the visual environment and returns the same instance.
214+
/// </summary>
215+
/// <typeparam name="T">The visual type.</typeparam>
216+
/// <typeparam name="TStyle">The style type.</typeparam>
217+
/// <param name="obj">The visual to style.</param>
218+
/// <param name="style">The style factory.</param>
219+
/// <returns>The same instance for chaining.</returns>
220+
public static T Style<T, TStyle>(this T obj, Func<TStyle> style) where T : Visual where TStyle : IStyle<TStyle>
221+
{
222+
ArgumentNullException.ThrowIfNull(obj);
223+
ArgumentNullException.ThrowIfNull(style);
224+
obj.VerifyAccess();
225+
obj.SetStyle(style);
226+
return obj;
227+
}
228+
229+
/// <summary>
230+
/// Applies a style binding to a visual by storing it in the visual environment and returns the same instance.
231+
/// </summary>
232+
/// <typeparam name="T">The visual type.</typeparam>
233+
/// <typeparam name="TStyle">The style type.</typeparam>
234+
/// <param name="obj">The visual to style.</param>
235+
/// <param name="style">The style binding.</param>
236+
/// <returns>The same instance for chaining.</returns>
237+
public static T Style<T, TStyle>(this T obj, Binding<TStyle> style) where T : Visual where TStyle : IStyle<TStyle>
238+
{
239+
ArgumentNullException.ThrowIfNull(obj);
240+
if (style.IsEmpty)
241+
{
242+
throw new ArgumentException("The style binding cannot be empty.", nameof(style));
243+
}
244+
obj.VerifyAccess();
245+
obj.SetStyle(style);
246+
return obj;
247+
}
248+
249+
/// <summary>
250+
/// Applies a style state to a visual by storing it in the visual environment and returns the same instance.
251+
/// </summary>
252+
/// <typeparam name="T">The visual type.</typeparam>
253+
/// <typeparam name="TStyle">The style type.</typeparam>
254+
/// <param name="obj">The visual to style.</param>
255+
/// <param name="style">The state that provides style values.</param>
256+
/// <returns>The same instance for chaining.</returns>
257+
public static T Style<T, TStyle>(this T obj, State<TStyle> style) where T : Visual where TStyle : IStyle<TStyle>
258+
{
259+
ArgumentNullException.ThrowIfNull(style);
260+
return obj.Style((Binding<TStyle>)style);
261+
}
211262
}

0 commit comments

Comments
 (0)