fix(ui): stop NumericUpDown from writing null into non-nullable settings

Clearing the text box to type a new value sets Value to null, which the TwoWay
binding then wrote into an int/decimal target -- InvalidCastException on the
normal way of editing eight settings fields. KeepLastNumberConverter maps that
null to BindingOperations.DoNothing so the source keeps its last value.
This commit is contained in:
mika kuns
2026-08-06 10:20:53 +02:00
parent 091aca521f
commit 56f7d64f07
3 changed files with 33 additions and 8 deletions
+1
View File
@@ -22,6 +22,7 @@
<converters:BoolToItalicConverter x:Key="BoolToItalic"/>
<converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/>
<converters:LogKindForegroundConverter x:Key="LogKindForeground"/>
<converters:KeepLastNumberConverter x:Key="KeepLastNumber"/>
</ResourceDictionary>
</Application.Resources>
@@ -0,0 +1,24 @@
using System.Globalization;
using Avalonia.Data;
using Avalonia.Data.Converters;
namespace ClaudeDo.Ui.Converters;
/// <summary>
/// For <c>NumericUpDown.Value</c> bound to a non-nullable numeric property. The control's Value is
/// <c>decimal?</c> and goes null the moment the text box is empty — which is exactly what happens
/// while the user clears a value to type a new one. Writing that null into an <c>int</c>/<c>decimal</c>
/// target throws <see cref="InvalidCastException"/>, so swallow it and leave the source untouched
/// until a real number arrives.
/// </summary>
public class KeepLastNumberConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is null ? null : System.Convert.ToDecimal(value, culture);
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is null) return BindingOperations.DoNothing;
return System.Convert.ChangeType(value, Nullable.GetUnderlyingType(targetType) ?? targetType, culture);
}
}
@@ -135,7 +135,7 @@
<ComboBox Grid.Column="2" ItemsSource="{Binding EffortLevels}"
SelectedItem="{Binding Effort, Mode=TwoWay}"
HorizontalAlignment="Stretch"/>
<NumericUpDown Grid.Column="4" Value="{Binding MaxTurns, Mode=TwoWay}"
<NumericUpDown Grid.Column="4" Value="{Binding MaxTurns, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="1" Maximum="200" Increment="1" FormatString="0"/>
</Grid>
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
@@ -147,7 +147,7 @@
</ItemsControl>
<StackPanel Spacing="4" Margin="0,4,0,0">
<TextBlock Classes="eyebrow" Text="{loc:Tr settings.general.maxTurnsCeiling}"/>
<NumericUpDown Value="{Binding General.MaxTurnsCeiling, Mode=TwoWay}"
<NumericUpDown Value="{Binding General.MaxTurnsCeiling, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="1" Maximum="200" Increment="10" FormatString="0"
HorizontalAlignment="Left" Width="140"/>
<TextBlock Text="{loc:Tr settings.general.maxTurnsCeilingHint}"
@@ -156,7 +156,7 @@
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.maxParallelExecutions}"/>
<NumericUpDown Value="{Binding General.MaxParallelExecutions, Mode=TwoWay}"
<NumericUpDown Value="{Binding General.MaxParallelExecutions, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="1" Maximum="20" Increment="1" FormatString="0"
HorizontalAlignment="Left" Width="140"/>
<TextBlock Text="{loc:Tr settings.general.maxParallelExecutionsHint}"
@@ -169,13 +169,13 @@
<StackPanel Orientation="Horizontal" Spacing="16">
<StackPanel Spacing="4">
<TextBlock Classes="eyebrow" Text="{loc:Tr settings.general.usageGateFiveHourPct}"/>
<NumericUpDown Value="{Binding General.UsageGateFiveHourPct, Mode=TwoWay}"
<NumericUpDown Value="{Binding General.UsageGateFiveHourPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="0" Maximum="100" Increment="5" FormatString="0"
HorizontalAlignment="Left" Width="140"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Classes="eyebrow" Text="{loc:Tr settings.general.usageGateSevenDayPct}"/>
<NumericUpDown Value="{Binding General.UsageGateSevenDayPct, Mode=TwoWay}"
<NumericUpDown Value="{Binding General.UsageGateSevenDayPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="0" Maximum="100" Increment="5" FormatString="0"
HorizontalAlignment="Left" Width="140"/>
</StackPanel>
@@ -234,7 +234,7 @@
<CheckBox IsChecked="{Binding Worktrees.WorktreeAutoCleanupEnabled, Mode=TwoWay}"
Content="{loc:Tr settings.worktrees.autoCleanup}"
VerticalAlignment="Center"/>
<NumericUpDown Value="{Binding Worktrees.WorktreeAutoCleanupDays, Mode=TwoWay}"
<NumericUpDown Value="{Binding Worktrees.WorktreeAutoCleanupDays, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Width="130" Minimum="1" Maximum="365" Increment="1" FormatString="0"
IsEnabled="{Binding Worktrees.WorktreeAutoCleanupEnabled}"/>
<TextBlock Classes="body" Text="{loc:Tr settings.worktrees.days}" VerticalAlignment="Center"/>
@@ -384,7 +384,7 @@
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Classes="field-label" Text="{loc:Tr settings.prime.dailyPrepMaxTasks}" VerticalAlignment="Center"/>
<NumericUpDown Minimum="1" Maximum="50" Increment="1" Width="100" FormatString="0"
Value="{Binding Prime.DailyPrepMaxTasks, Mode=TwoWay}"/>
Value="{Binding Prime.DailyPrepMaxTasks, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
@@ -466,7 +466,7 @@
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.pollIntervalLabel}"/>
<NumericUpDown Value="{Binding OnlineInbox.PollIntervalSeconds, Mode=TwoWay}"
<NumericUpDown Value="{Binding OnlineInbox.PollIntervalSeconds, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="10" Maximum="3600" Increment="10" FormatString="0"
HorizontalAlignment="Left" Width="140"/>
</StackPanel>