feat(lists): selectable list/folder colors with accent-aware swatches and custom picker

This commit is contained in:
mika kuns
2026-10-01 10:29:53 +02:00
parent d316f3c6a5
commit a6926f53b5
26 changed files with 1529 additions and 45 deletions
+14 -4
View File
@@ -5,20 +5,30 @@ using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui;
public sealed record AccentPreset(string Name, string Accent, string Dim, string Soft, string Glow);
/// <summary>An accent theme; <paramref name="Swatches"/> holds one hex per <c>SwatchNBrush</c> token (index 0 = Swatch1).</summary>
public sealed record AccentPreset(string Name, string Accent, string Dim, string Soft, string Glow, string[] Swatches);
public static class AccentPresets
{
// Hue 88 — moss/sage green (original)
public static readonly AccentPreset Moss = new("moss", "#FF7C9166", "#FF64785A", "#FF3E4B39", "#387C9166");
public static readonly AccentPreset Moss = new("moss", "#FF7C9166", "#FF64785A", "#FF3E4B39", "#387C9166",
["#7C9166", "#D4A574", "#C87060", "#D9C27A", "#6FB3A8", "#7FA7C9", "#A38BC9", "#C77FA0", "#8B9D7A", "#B58B6A", "#5C78B8", "#9AA0A6"]);
// Hue ~40 — warm earthy brown/terra
public static readonly AccentPreset Peat = new("peat", "#FF9A7B5C", "#FF7F6449", "#FF4D3C2C", "#389A7B5C");
public static readonly AccentPreset Peat = new("peat", "#FF9A7B5C", "#FF7F6449", "#FF4D3C2C", "#389A7B5C",
["#9A7B5C", "#D9A066", "#C4604F", "#DBBF6A", "#7FA890", "#7C9AB8", "#A98BB8", "#C98A8A", "#A39A6E", "#B8825A", "#6E80AE", "#A09A90"]);
// Hue ~180 — cool teal/sea-green
public static readonly AccentPreset Sea = new("sea", "#FF5B8F8C", "#FF4A7573", "#FF263D3C", "#385B8F8C");
public static readonly AccentPreset Sea = new("sea", "#FF5B8F8C", "#FF4A7573", "#FF263D3C", "#385B8F8C",
["#5B8F8C", "#C9A87C", "#BF7468", "#CBC27F", "#4FA396", "#5FA8C8", "#8E8BCC", "#B87FA5", "#7C9A8E", "#A8907A", "#5C78B8", "#8FA0A8"]);
public static readonly IReadOnlyList<AccentPreset> All = [Moss, Peat, Sea];
public static AccentPreset Default => Moss;
/// <summary>Number of <c>SwatchNBrush</c> tokens every preset must define.</summary>
public const int SwatchCount = 12;
/// <summary>Swatch keys ("Swatch1".."SwatchN") as stored on lists and folders.</summary>
public static readonly string[] SwatchKeys = Enumerable.Range(1, SwatchCount).Select(i => $"Swatch{i}").ToArray();
public static AccentPreset Find(string? name) =>
All.FirstOrDefault(p => p.Name == name) ?? Default;
}
+1 -1
View File
@@ -107,7 +107,7 @@ new editor boilerplate there rather than copying it a third time.
## Converters
In `Converters/` — grep rather than list: log-level brush, dot brush,
In `Converters/` — `ColorBrushConverter` (in `SimpleConverters.cs`) is the one resolver for stored list/folder colours (swatch key → `SwatchNBrush`, `#RRGGBB` → brush, else the `ConverterParameter` resource); the `Swatch1-12Brush` tokens are rewritten in place per accent preset by `AccentPresetService`, so every `AccentPreset` must carry all `AccentPresets.SwatchCount` hexes. Otherwise grep rather than list: log-level brush, dot brush,
status equality, icon key, `KeepLastNumber`. The one-expression ones
(strike/italic/opacity toggles, uppercase, not-a-header-row) share
`SimpleConverters.cs`; put new trivial ones there instead of a new file.
+1
View File
@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="12.0.4" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="12.0.0" />
<PackageReference Include="Material.Icons" Version="3.0.2" />
<PackageReference Include="TextMateSharp.Grammars" Version="2.0.3" />
+24 -6
View File
@@ -9,14 +9,32 @@ namespace ClaudeDo.Ui.Converters;
// instantiate a generic FuncValueConverter as a resource; they stay one per type so the
// x:Key names in App.axaml keep resolving. Anything with real logic gets its own file.
/// <summary>"#RRGGBB" to a brush; anything else falls back to the app resource named by the parameter.</summary>
public sealed class HexBrushConverter : OneWayConverter
/// <summary>
/// A stored list/folder colour to a brush: "#RRGGBB" becomes a <see cref="SolidColorBrush"/>, a swatch key
/// ("Swatch3") resolves to the app resource "Swatch3Brush"; null, empty or unknown falls back to the resource named by the parameter.
/// </summary>
public sealed class ColorBrushConverter : OneWayConverter
{
public static HexBrushConverter Instance { get; } = new();
public static ColorBrushConverter Instance { get; } = new();
public override object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is string s && Color.TryParse(s, out var c)
? new SolidColorBrush(c)
: Avalonia.Application.Current?.TryGetResource(parameter as string ?? "", null, out var res) == true ? res : null;
=> Resolve(value as string, parameter as string, LookupResource);
private static object? LookupResource(string key)
=> Avalonia.Application.Current?.TryGetResource(key, null, out var res) == true ? res : null;
/// <summary>Resolves a stored colour against <paramref name="lookup"/> (app resources in production); split out so tests need no Avalonia app.</summary>
internal static object? Resolve(string? value, string? fallbackKey, Func<string, object?> lookup)
{
if (value is { Length: > 0 })
{
if (value[0] == '#')
{
if (Color.TryParse(value, out var c)) return new SolidColorBrush(c);
}
else if (lookup(value + "Brush") is { } swatch) return swatch;
}
return fallbackKey is null ? null : lookup(fallbackKey);
}
}
/// <summary>True when the value's string form equals the parameter (segmented-switch active state).</summary>
+15
View File
@@ -114,6 +114,21 @@
<SolidColorBrush x:Key="Identity5Brush" Color="#C77FA0" />
<SolidColorBrush x:Key="Identity6Brush" Color="#5C78B8" />
<!-- Default colour swatches for lists and folders. Hexes are the Moss preset's; AccentPresetService
rewrites them in place on a preset switch, so everything bound to a swatch re-tints live. -->
<SolidColorBrush x:Key="Swatch1Brush" Color="#7C9166" />
<SolidColorBrush x:Key="Swatch2Brush" Color="#D4A574" />
<SolidColorBrush x:Key="Swatch3Brush" Color="#C87060" />
<SolidColorBrush x:Key="Swatch4Brush" Color="#D9C27A" />
<SolidColorBrush x:Key="Swatch5Brush" Color="#6FB3A8" />
<SolidColorBrush x:Key="Swatch6Brush" Color="#7FA7C9" />
<SolidColorBrush x:Key="Swatch7Brush" Color="#A38BC9" />
<SolidColorBrush x:Key="Swatch8Brush" Color="#C77FA0" />
<SolidColorBrush x:Key="Swatch9Brush" Color="#8B9D7A" />
<SolidColorBrush x:Key="Swatch10Brush" Color="#B58B6A" />
<SolidColorBrush x:Key="Swatch11Brush" Color="#5C78B8" />
<SolidColorBrush x:Key="Swatch12Brush" Color="#9AA0A6" />
<!-- Merge editor (3-pane conflict resolver) block tints -->
<SolidColorBrush x:Key="MergeOursTintBrush" Color="#1F7C9166" /> <!-- ours side (moss) -->
<SolidColorBrush x:Key="MergeTheirsTintBrush" Color="#1FD4A574" /> <!-- theirs side (amber) -->
@@ -14,6 +14,8 @@ public static class AccentPresetService
SetBrushColor(app, "AccentSoftBrush", preset.Soft);
SetBrushColor(app, "AccentGlowBrush", preset.Glow);
SetBrushColor(app, "MossBrush", preset.Accent);
for (var i = 0; i < preset.Swatches.Length; i++)
SetBrushColor(app, $"Swatch{i + 1}Brush", preset.Swatches[i]);
}
private static void SetBrushColor(Application app, string key, string hex)
@@ -38,8 +38,16 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
/// <summary>No icon picked — the row shows a coloured dot instead of a glyph.</summary>
public bool IsDot => IconKey is null or ListsIslandViewModel.ListIcon;
/// <summary>Picked colour (swatch key or "#RRGGBB"); null = the hash-derived identity colour.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DotColor))]
private string? _color;
/// <summary>Identity colour of the dot, stable per list id across restarts.</summary>
public string DotColorKey => $"Identity{ConPtyPaneViewModel.StableIndex(Id, 6) + 1}";
/// <summary>What the dot renders: the picked colour, else <see cref="DotColorKey"/>.</summary>
public string DotColor => string.IsNullOrEmpty(Color) ? DotColorKey : Color;
// The sidebar folder this row sits in (ListEntity.FolderId); null = top level.
[ObservableProperty] private string? _folderId;
}
@@ -66,7 +66,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
vm.BeginLoad(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey);
vm.BeginLoad(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey, color: row.Color);
await Dialogs.ShowListSettingsAsync(vm);
if (vm.Deleted) await LoadAsync();
else await RefreshRowAsync(row.Id);
@@ -373,6 +373,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Name = l.Name,
Kind = ListKind.User,
IconKey = l.Icon ?? ListIcon,
Color = l.Color,
WorkingDir = l.WorkingDir,
DefaultCommitType = l.DefaultCommitType,
IsManual = l.IsManual,
@@ -492,7 +493,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
// canToggleFolder: only the create flow offers "make this a folder instead".
vm.BeginLoad(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon, canToggleFolder: true);
vm.BeginLoad(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon, canToggleFolder: true, color: entity.Color);
await Dialogs.ShowListSettingsAsync(vm);
if (!vm.Saved && !vm.Deleted)
{
@@ -845,6 +846,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
row.IsManual = entity.IsManual;
row.FindingsTracked = entity.FindingsTracked;
row.IconKey = entity.Icon ?? ListIcon;
row.Color = entity.Color;
row.HasTicketProject = hasTicketProject;
RecomputeHasNoLinkedRepo();
}
@@ -891,6 +893,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Name = entity.Name,
Kind = ListKind.User,
IconKey = entity.Icon ?? ListIcon,
Color = entity.Color,
WorkingDir = entity.WorkingDir,
DefaultCommitType = entity.DefaultCommitType,
IsManual = entity.IsManual,
@@ -115,12 +115,42 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
public const string DefaultIcon = "Dot";
/// <summary>Folder colour swatches; "" = no colour (theme default).</summary>
public static readonly string[] FolderColorOptions =
["", "#D4A574", "#C87060", "#D9C27A", "#8B9D7A", "#6FB3A8", "#7FA7C9", "#A38BC9"];
/// <summary>Picker swatches: "" (default) followed by the accent-aware palette keys.</summary>
public static readonly string[] SwatchOptions = ["", .. AccentPresets.SwatchKeys];
/// <summary>Selected folder colour, "" for none.</summary>
[ObservableProperty] private string _folderColor = "";
/// <summary>Stored colour: "" (default), a swatch key, or a literal "#RRGGBB". Shared by lists and folders.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsCustomColor))]
private string _selectedColor = "";
/// <summary>True when <see cref="SelectedColor"/> is a custom hex rather than a swatch.</summary>
public bool IsCustomColor => SelectedColor.StartsWith('#');
// Separate from SelectedColor: the ListBox pushes null when a custom hex (not in SwatchOptions) is current.
[ObservableProperty] private string? _selectedSwatch = "";
/// <summary>Bound to the custom ColorPicker; writes back as "#RRGGBB".</summary>
[ObservableProperty] private Avalonia.Media.Color _customColor = Avalonia.Media.Color.Parse(AccentPresets.Default.Accent);
private bool _syncingColor;
partial void OnSelectedColorChanged(string value)
{
_syncingColor = true;
SelectedSwatch = SwatchOptions.Contains(value) ? value : null;
if (IsCustomColor && Avalonia.Media.Color.TryParse(value, out var c)) CustomColor = c;
_syncingColor = false;
}
partial void OnSelectedSwatchChanged(string? value)
{
if (!_syncingColor && value is not null) SelectedColor = value;
}
partial void OnCustomColorChanged(Avalonia.Media.Color value)
{
if (!_syncingColor) SelectedColor = $"#{value.R:X2}{value.G:X2}{value.B:X2}";
}
/// <summary>Default icon for folders, kept apart from <see cref="DefaultIcon"/> so lists never look like folders.</summary>
public const string DefaultFolderIcon = "Folder";
@@ -226,10 +256,11 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
bool isManual = false,
bool findingsTracked = false,
string? icon = null,
bool canToggleFolder = false)
bool canToggleFolder = false,
string? color = null)
{
CanToggleFolder = canToggleFolder;
_ = LoadAsync(listId, name, workingDir, defaultCommitType, isManual, findingsTracked, icon);
_ = LoadAsync(listId, name, workingDir, defaultCommitType, isManual, findingsTracked, icon, color);
}
/// <summary>
@@ -238,7 +269,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
/// </summary>
public void BeginLoadFolder(string folderId, string name, string? icon, string? color = null)
{
FolderColor = color ?? "";
SelectedColor = color ?? "";
_folderId = folderId;
IsFolder = true;
CanToggleFolder = false;
@@ -254,6 +285,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
bool isManual = false,
bool findingsTracked = false,
string? icon = null,
string? color = null,
CancellationToken ct = default)
{
ListConfigDto? cfg = null;
@@ -268,6 +300,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
// An unknown/legacy key would leave the picker with no selection and silently reset the
// icon on the next save — fall back to the default instead.
Icon = IsKnownIcon(icon) ? icon! : DefaultIcon;
SelectedColor = color ?? "";
WorkingDir = workingDir ?? "";
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
@@ -364,7 +397,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
DefaultCommitType,
IsManual,
FindingsTracked,
Icon));
Icon,
NullIfEmpty(SelectedColor)));
// Tri-state: dropdown never shown (no base URL), or ticket projects failed/never loaded
// (e.g. an unreachable ticket system) -> null, leave the stored link alone. Dropdown shown
@@ -398,7 +432,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
Id = Guid.NewGuid().ToString("N"),
Name = name,
Icon = Icon,
Color = NullIfEmpty(FolderColor),
Color = NullIfEmpty(SelectedColor),
CreatedAt = DateTime.UtcNow,
});
// The create flow pre-creates a list row for this modal to save against; a folder
@@ -409,7 +443,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
}
else
{
await lists.RenameFolderAsync(_folderId, name, Icon, NullIfEmpty(FolderColor));
await lists.RenameFolderAsync(_folderId, name, Icon, NullIfEmpty(SelectedColor));
}
Saved = true;
@@ -92,7 +92,7 @@
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
VerticalAlignment="Center"/>
<Ellipse Width="8" Height="8" IsVisible="{Binding IsDot}"
Fill="{Binding DotColorKey, Converter={StaticResource DotBrush}}"
Fill="{Binding DotColor, Converter={x:Static converters:ColorBrushConverter.Instance}, ConverterParameter=MossBrush}"
VerticalAlignment="Center"/>
</Panel>
<!-- Name -->
@@ -137,24 +137,24 @@
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="12,20,*,Auto">
<PathIcon Grid.Column="0" Classes="list-icon" Foreground="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=TextMuteBrush}"
<PathIcon Grid.Column="0" Classes="list-icon" Foreground="{Binding Color, Converter={x:Static converters:ColorBrushConverter.Instance}, ConverterParameter=TextMuteBrush}"
Width="9" Height="9"
Data="{StaticResource Icon.ChevronRight}"
IsVisible="{Binding IsCollapsed}"
HorizontalAlignment="Left" VerticalAlignment="Center"/>
<PathIcon Grid.Column="0" Classes="list-icon" Foreground="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=TextMuteBrush}"
<PathIcon Grid.Column="0" Classes="list-icon" Foreground="{Binding Color, Converter={x:Static converters:ColorBrushConverter.Instance}, ConverterParameter=TextMuteBrush}"
Width="9" Height="9"
Data="{StaticResource Icon.ChevronDown}"
IsVisible="{Binding !IsCollapsed}"
HorizontalAlignment="Left" VerticalAlignment="Center"/>
<PathIcon Grid.Column="1" Classes="list-icon" Foreground="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=TextMuteBrush}"
<PathIcon Grid.Column="1" Classes="list-icon" Foreground="{Binding Color, Converter={x:Static converters:ColorBrushConverter.Instance}, ConverterParameter=TextMuteBrush}"
Width="14" Height="14"
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Classes="list-label"
Text="{Binding Name}" ToolTip.Tip="{Binding Name}"
VerticalAlignment="Center" Margin="8,0" FontWeight="SemiBold"
Foreground="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=TextDimBrush}"
Foreground="{Binding Color, Converter={x:Static converters:ColorBrushConverter.Instance}, ConverterParameter=TextDimBrush}"
FontSize="{StaticResource FontSizeBody}"/>
<TextBlock Grid.Column="3" Classes="list-count"
Text="{Binding Count}" IsVisible="{Binding ShowCount}"/>
@@ -166,7 +166,7 @@
<ctl:ExpandPanel IsExpanded="{Binding !IsCollapsed}">
<Panel>
<Border Width="1" HorizontalAlignment="Left" Margin="8,0,0,4"
Background="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=LineBrush}"
Background="{Binding Color, Converter={x:Static converters:ColorBrushConverter.Instance}, ConverterParameter=LineBrush}"
IsHitTestVisible="False"/>
<ItemsControl ItemsSource="{Binding Children}" Margin="14,0,0,0"/>
</Panel>
@@ -89,12 +89,28 @@
<TextBox Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding IsFolder}">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.folderColor}"/>
<ListBox ItemsSource="{x:Static vm:ListSettingsModalViewModel.FolderColorOptions}"
SelectedItem="{Binding FolderColor, Mode=TwoWay}"
<!-- Colour: swatches follow the accent preset (brushes mutated in place), the picker stores a literal hex.
The empty ring is "default". Shared by lists (dot) and folders (name + guide line). -->
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.color}"/>
<ListBox ItemsSource="{x:Static vm:ListSettingsModalViewModel.SwatchOptions}"
SelectedItem="{Binding SelectedSwatch, Mode=TwoWay}"
Background="Transparent" BorderThickness="0"
HorizontalAlignment="Left">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="2"/>
<Setter Property="MinHeight" Value="0"/>
</Style>
<Style Selector="Border.swatch">
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="BorderBrush" Value="{DynamicResource LineBrightBrush}"/>
</Style>
<Style Selector="ListBoxItem:selected Border.swatch">
<Setter Property="BorderThickness" Value="2"/>
<Setter Property="BorderBrush" Value="{DynamicResource TextBrush}"/>
</Style>
</ListBox.Styles>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
@@ -102,14 +118,27 @@
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="x:String">
<!-- "" renders as an empty ring: the "no colour" swatch. -->
<Border Width="18" Height="18" Margin="3" CornerRadius="9"
BorderThickness="1" BorderBrush="{DynamicResource LineBrightBrush}"
Background="{Binding Converter={x:Static conv:HexBrushConverter.Instance}, ConverterParameter=Surface2Brush}"/>
<Border Classes="swatch" Width="20" Height="20" CornerRadius="10"
Background="{Binding Converter={x:Static conv:ColorBrushConverter.Instance}, ConverterParameter=Surface2Brush}"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Text="{loc:Tr modals.listSettings.folderColorHint}"
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,4,0,0">
<TextBlock Text="{loc:Tr modals.listSettings.colorCustom}" VerticalAlignment="Center"/>
<Border Classes="custom-swatch" Classes.on="{Binding IsCustomColor}" CornerRadius="4" Padding="2">
<Border.Styles>
<Style Selector="Border.custom-swatch">
<Setter Property="BorderThickness" Value="2"/>
<Setter Property="BorderBrush" Value="Transparent"/>
</Style>
<Style Selector="Border.custom-swatch.on">
<Setter Property="BorderBrush" Value="{DynamicResource TextBrush}"/>
</Style>
</Border.Styles>
<ColorPicker Color="{Binding CustomColor, Mode=TwoWay}" IsAlphaEnabled="False" IsAlphaVisible="False"/>
</Border>
</StackPanel>
<TextBlock Text="{loc:Tr modals.listSettings.colorHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>