diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2a20b59..614823a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,14 @@ broke. The heading is parsed by `scripts/release-notes.ps1`, so keep its shape;
under it is ordinary Markdown, and the renderer handles paragraphs, lists, links, `code`
and **bold**.
+## 1.5.1 - 14 September 2026
+
+### Imported containers lay out at the spacing you choose
+**Preferences → Import** sets the **Horizontal spacing** between containers in a row and the
+**Vertical spacing** between rows, from 0 to 500 px at 100% zoom. Both start at 32 px, the
+gap an import has always used, and a change applies to the next file you import rather than
+to containers already on the board.
+
## 1.5.0 - 11 September 2026
### The board you had open comes back
diff --git a/Directory.Build.props b/Directory.Build.props
index f5aa46b..674bd28 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -5,7 +5,7 @@
scripts/build-installer.ps1 both read it from here, so releasing is a reviewed change
to this line rather than an edit in a pipeline variable group.
-->
- 1.5.0
+ 1.5.1
latest
enable
enable
diff --git a/docs/wimport.md b/docs/wimport.md
index 8dedd90..0b4f0e2 100644
--- a/docs/wimport.md
+++ b/docs/wimport.md
@@ -86,6 +86,12 @@ Whiteboard measures each container, then packs them **left to right**. A themati
starts a new row even if the current row is not full. Without a break, a row wraps when the
next item would exceed about 2400 world units.
+In Preferences → Import, **Horizontal spacing** sets the gap between side-by-side
+containers, and **Vertical spacing** sets the gap below the tallest container when a new
+row starts, whether from a break or automatic wrapping. Both default to **32 px** and range
+from 0 to 500 px at 100% zoom. Changes apply to future imports, not containers already on
+the board.
+
On drop, the group’s **top-left** is the pointer. On Open or File → Import, the group’s
top-left is the top-left of the visible view.
diff --git a/site/wimport.html b/site/wimport.html
index 61a5a62..f8a84fb 100644
--- a/site/wimport.html
+++ b/site/wimport.html
@@ -95,6 +95,7 @@
Languages shipped today
Layout
Whiteboard measures each container, then packs them left to right. A thematic break starts a new row even if the current row is not full. Without a break, a row wraps when the next item would exceed about 2400 world units.
+ In Preferences → Import, set Horizontal spacing between containers and Vertical spacing between rows. Both default to 32 px and range from 0 to 500 px at 100% zoom. Changes apply to future imports only.
On drop, the group’s top-left is the pointer. On Open or toolbar Import, the group’s top-left is the top-left of the visible view. Do not put coordinates in the file. There is no pos:, no YAML front matter, and no HTML comment layout.
How the file is opened
diff --git a/src/SQLBI.Whiteboard.Core/Import/ImportLayout.cs b/src/SQLBI.Whiteboard.Core/Import/ImportLayout.cs
index e049b9b..f080105 100644
--- a/src/SQLBI.Whiteboard.Core/Import/ImportLayout.cs
+++ b/src/SQLBI.Whiteboard.Core/Import/ImportLayout.cs
@@ -12,7 +12,9 @@ public static class ImportLayout
public static IReadOnlyList Place(
IReadOnlyList<(double Width, double Height, bool StartNewRow)> items,
- PointD originTopLeft)
+ PointD originTopLeft,
+ double horizontalSpacing = Gap,
+ double verticalSpacing = Gap)
{
ArgumentNullException.ThrowIfNull(items);
var placed = new RectD[items.Count];
@@ -30,13 +32,13 @@ public static IReadOnlyList Place(
if (wrap)
{
x = originTopLeft.X;
- y += rowHeight + Gap;
+ y += rowHeight + verticalSpacing;
rowHeight = 0;
rowOccupied = false;
}
placed[index] = new RectD(x, y, width, height);
- x += width + Gap;
+ x += width + horizontalSpacing;
rowHeight = Math.Max(rowHeight, height);
rowOccupied = true;
}
diff --git a/src/SQLBI.Whiteboard.Core/Settings/AppSettings.cs b/src/SQLBI.Whiteboard.Core/Settings/AppSettings.cs
index 37ade84..f62c54a 100644
--- a/src/SQLBI.Whiteboard.Core/Settings/AppSettings.cs
+++ b/src/SQLBI.Whiteboard.Core/Settings/AppSettings.cs
@@ -150,6 +150,8 @@ public sealed class AppSettings
public ExportSettings Export { get; set; } = new();
+ public ImportSettings Import { get; set; } = new();
+
///
/// Whether to say at startup that Windows reports nothing to draw with. The
/// tablet list this reads is a list of digitizers rather than an answer
@@ -180,7 +182,7 @@ public sealed class AppSettings
public static class AppSettingsSerializer
{
- public const int CurrentVersion = 17;
+ public const int CurrentVersion = 18;
///
/// The version that moved plain text to the end of the default snippet
@@ -293,6 +295,7 @@ private static AppSettings Normalize(AppSettings? settings)
settings.Laser = LaserSettings.Normalize(settings.Laser);
settings.PenButtons = PenButtonSettings.Normalize(settings.PenButtons);
settings.Export = ExportSettings.Normalize(settings.Export);
+ settings.Import = ImportSettings.Normalize(settings.Import);
if (settings.Version < VersionWithPlainTextLast &&
TextLanguageIds.IsLegacyDefaultOrder(settings.SnippetFormatOrder))
{
diff --git a/src/SQLBI.Whiteboard.Core/Settings/ImportSettings.cs b/src/SQLBI.Whiteboard.Core/Settings/ImportSettings.cs
new file mode 100644
index 0000000..83b06fa
--- /dev/null
+++ b/src/SQLBI.Whiteboard.Core/Settings/ImportSettings.cs
@@ -0,0 +1,26 @@
+using SQLBI.Whiteboard.Core.Import;
+
+namespace SQLBI.Whiteboard.Core.Settings;
+
+public sealed class ImportSettings
+{
+ public const double DefaultSpacing = ImportLayout.Gap;
+ public const double MinimumSpacing = 0;
+ public const double MaximumSpacing = 500;
+
+ // Board pixels at 100% zoom, independent of the view used to import the file.
+ public double HorizontalSpacing { get; set; } = DefaultSpacing;
+
+ public double VerticalSpacing { get; set; } = DefaultSpacing;
+
+ public static ImportSettings Normalize(ImportSettings? settings)
+ {
+ var result = settings ?? new ImportSettings();
+ result.HorizontalSpacing = Clamp(result.HorizontalSpacing);
+ result.VerticalSpacing = Clamp(result.VerticalSpacing);
+ return result;
+ }
+
+ private static double Clamp(double value) =>
+ double.IsFinite(value) ? Math.Clamp(value, MinimumSpacing, MaximumSpacing) : DefaultSpacing;
+}
diff --git a/src/SQLBI.Whiteboard/MainWindow.xaml.cs b/src/SQLBI.Whiteboard/MainWindow.xaml.cs
index bdf1cba..d92752a 100644
--- a/src/SQLBI.Whiteboard/MainWindow.xaml.cs
+++ b/src/SQLBI.Whiteboard/MainWindow.xaml.cs
@@ -5402,7 +5402,11 @@ private void ApplyImport(ImportDocument imported, PointD originTopLeft, bool rec
return;
}
- var rects = ImportLayout.Place(sizes, originTopLeft);
+ var rects = ImportLayout.Place(
+ sizes,
+ originTopLeft,
+ _settings.Import.HorizontalSpacing,
+ _settings.Import.VerticalSpacing);
var objects = new List(decoded.Count);
var assets = new List();
for (var index = 0; index < decoded.Count; index++)
diff --git a/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs b/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs
index 049582f..a82e8e0 100644
--- a/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs
+++ b/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs
@@ -265,9 +265,9 @@ private Border CreateRow(SettingDescriptor setting, string? query)
var value = new TextBlock
{
Style = (Style)FindResource("SettingsValueLabel"),
- Text = FormatSeconds(slider.Value),
+ Text = FormatRangeValue(setting, slider.Value),
};
- slider.ValueChanged += (_, _) => value.Text = FormatSeconds(slider.Value);
+ slider.ValueChanged += (_, _) => value.Text = FormatRangeValue(setting, slider.Value);
Grid.SetColumn(value, 1);
Grid.SetRow(slider, 1);
Grid.SetColumnSpan(slider, 2);
@@ -908,20 +908,26 @@ private ToggleButton CreateSwitch(SettingDescriptor setting)
private Slider CreateSlider(SettingDescriptor setting)
{
- var value = setting.Id == SettingsCatalog.Ids.LaserFadeSeconds
- ? _settings.Laser.FadeSeconds
- : _settings.Laser.HoldSeconds;
+ var value = setting.Id switch
+ {
+ SettingsCatalog.Ids.LaserFadeSeconds => _settings.Laser.FadeSeconds,
+ SettingsCatalog.Ids.LaserHoldSeconds => _settings.Laser.HoldSeconds,
+ SettingsCatalog.Ids.ImportHorizontalSpacing => _settings.Import.HorizontalSpacing,
+ SettingsCatalog.Ids.ImportVerticalSpacing => _settings.Import.VerticalSpacing,
+ _ => throw new ArgumentException("Unknown range setting.", nameof(setting)),
+ };
var slider = new Slider
{
Style = (Style)FindResource("SettingsSlider"),
Minimum = setting.Minimum,
Maximum = setting.Maximum,
- SmallChange = setting.Id == SettingsCatalog.Ids.LaserFadeSeconds ? 0.05 : 0.25,
- LargeChange = setting.Id == SettingsCatalog.Ids.LaserFadeSeconds ? 0.5 : 1,
- TickFrequency = setting.Id == SettingsCatalog.Ids.LaserFadeSeconds ? 0.05 : 0.25,
+ SmallChange = setting.SmallChange,
+ LargeChange = setting.LargeChange,
+ TickFrequency = setting.SmallChange,
IsSnapToTickEnabled = true,
Value = value,
};
+ AutomationProperties.SetName(slider, setting.Title);
slider.ValueChanged += (_, _) => SetRange(setting, slider.Value);
return slider;
}
@@ -1109,16 +1115,26 @@ private void SetRange(SettingDescriptor setting, double value)
return;
}
- if (setting.Id == SettingsCatalog.Ids.LaserFadeSeconds)
- {
- _settings.Laser.FadeSeconds = value;
- }
- else
+ switch (setting.Id)
{
- _settings.Laser.HoldSeconds = value;
+ case SettingsCatalog.Ids.LaserFadeSeconds:
+ _settings.Laser.FadeSeconds = value;
+ break;
+ case SettingsCatalog.Ids.LaserHoldSeconds:
+ _settings.Laser.HoldSeconds = value;
+ break;
+ case SettingsCatalog.Ids.ImportHorizontalSpacing:
+ _settings.Import.HorizontalSpacing = value;
+ break;
+ case SettingsCatalog.Ids.ImportVerticalSpacing:
+ _settings.Import.VerticalSpacing = value;
+ break;
+ default:
+ return;
}
_settings.Laser = LaserSettings.Normalize(_settings.Laser);
+ _settings.Import = ImportSettings.Normalize(_settings.Import);
NotifyApplied();
}
@@ -1195,11 +1211,11 @@ private static int DistinctCategoryCount(IReadOnlyList settin
return seen.Count;
}
- private static string FormatSeconds(double value)
+ private static string FormatRangeValue(SettingDescriptor setting, double value)
{
var rounded = Math.Round(value, 2, MidpointRounding.AwayFromZero);
var text = rounded.ToString(rounded == Math.Truncate(rounded) ? "0" : "0.##", CultureInfo.CurrentCulture);
- return $"{text} s";
+ return $"{text} {setting.Unit}";
}
private sealed class MonitorChoiceItem
diff --git a/src/SQLBI.Whiteboard/SettingsCatalog.cs b/src/SQLBI.Whiteboard/SettingsCatalog.cs
index 6847273..fb04137 100644
--- a/src/SQLBI.Whiteboard/SettingsCatalog.cs
+++ b/src/SQLBI.Whiteboard/SettingsCatalog.cs
@@ -87,6 +87,12 @@ internal sealed class SettingDescriptor
public double Maximum { get; init; }
+ public double SmallChange { get; init; } = 1;
+
+ public double LargeChange { get; init; } = 10;
+
+ public string Unit { get; init; } = string.Empty;
+
public bool HideInStore { get; init; }
}
@@ -96,6 +102,8 @@ public static class Ids
{
public const string StartupMonitor = "startup.monitor";
public const string StartFullScreen = "startup.fullscreen";
+ public const string ImportHorizontalSpacing = "import.horizontalSpacing";
+ public const string ImportVerticalSpacing = "import.verticalSpacing";
public const string LaserHoldSeconds = "laser.holdSeconds";
public const string LaserFadeSeconds = "laser.fadeSeconds";
public const string LaserHoldMode = "laser.holdMode";
@@ -127,12 +135,13 @@ public static class EraserButton
public const string Startup = "Startup";
public const string Input = "Input";
+ public const string Import = "Import";
public const string Laser = "Laser pointer";
public const string Toolbar = "Toolbar";
public const string Updates = "Updates";
public static IReadOnlyList Categories { get; } =
- [Startup, Input, Laser, Toolbar, Updates];
+ [Startup, Input, Import, Laser, Toolbar, Updates];
public static IReadOnlyList All { get; } =
[
@@ -244,6 +253,32 @@ public static class EraserButton
Editor = SettingEditorKind.OrderedList,
},
new()
+ {
+ Id = Ids.ImportHorizontalSpacing,
+ Category = Import,
+ Title = "Horizontal spacing",
+ Summary = "Space between imported containers in a row",
+ Description = "Pixels at 100% zoom. Applies to the next .wimport file, without rearranging existing containers. The default is 32 px.",
+ Keywords = ["import", "wimport", "horizontal", "spacing", "gap", "pixels", "layout"],
+ Editor = SettingEditorKind.DoubleRange,
+ Minimum = ImportSettings.MinimumSpacing,
+ Maximum = ImportSettings.MaximumSpacing,
+ Unit = "px",
+ },
+ new()
+ {
+ Id = Ids.ImportVerticalSpacing,
+ Category = Import,
+ Title = "Vertical spacing",
+ Summary = "Space between imported rows",
+ Description = "Pixels at 100% zoom, measured below the tallest item in the previous row. Applies to explicit line breaks and automatic wrapping in the next .wimport file. The default is 32 px.",
+ Keywords = ["import", "wimport", "vertical", "spacing", "gap", "pixels", "row", "line", "layout"],
+ Editor = SettingEditorKind.DoubleRange,
+ Minimum = ImportSettings.MinimumSpacing,
+ Maximum = ImportSettings.MaximumSpacing,
+ Unit = "px",
+ },
+ new()
{
Id = Ids.LaserHoldSeconds,
Category = Laser,
@@ -253,6 +288,9 @@ public static class EraserButton
Editor = SettingEditorKind.DoubleRange,
Minimum = LaserSettings.MinimumHoldSeconds,
Maximum = LaserSettings.MaximumHoldSeconds,
+ SmallChange = 0.25,
+ LargeChange = 1,
+ Unit = "s",
},
new()
{
@@ -264,6 +302,9 @@ public static class EraserButton
Editor = SettingEditorKind.DoubleRange,
Minimum = LaserSettings.MinimumFadeSeconds,
Maximum = LaserSettings.MaximumFadeSeconds,
+ SmallChange = 0.05,
+ LargeChange = 0.5,
+ Unit = "s",
},
new()
{
diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs
index 965d5eb..44a889c 100644
--- a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs
+++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs
@@ -331,6 +331,42 @@ kqlFromFence.Items is
placed[2].X == 10 && placed[2].Y > placed[0].Bottom &&
placed[4].Y > placed[3].Y,
"Flow layout should pack left to right, honor a forced row, and wrap on max width.");
+var spacedImport = ImportLayout.Place(
+ [(400, 200, false), (400, 300, false), (400, 200, true), (2400, 100, false), (500, 100, false)],
+ new PointD(10, 20),
+ horizontalSpacing: 80,
+ verticalSpacing: 120);
+Assert(
+ spacedImport.SequenceEqual(
+ [
+ new RectD(10, 20, 400, 200),
+ new RectD(490, 20, 400, 300),
+ new RectD(10, 440, 400, 200),
+ new RectD(10, 760, 2400, 100),
+ new RectD(10, 980, 500, 100),
+ ]),
+ "Custom spacing should separate columns horizontally and forced or wrapped rows vertically below the tallest item.");
+var touchingImport = ImportLayout.Place(
+ [(1200, 200, false), (1200, 300, false), (400, 100, false), (200, 100, true)],
+ new PointD(10, 20),
+ horizontalSpacing: 0,
+ verticalSpacing: 0);
+Assert(
+ touchingImport.SequenceEqual(
+ [
+ new RectD(10, 20, 1200, 200),
+ new RectD(1210, 20, 1200, 300),
+ new RectD(10, 320, 400, 100),
+ new RectD(10, 420, 200, 100),
+ ]),
+ "Zero spacing should allow touching edges and an exactly full row without overlapping containers.");
+Assert(
+ ImportLayout.Place(
+ [(1200, 200, false), (1200, 300, false)],
+ new PointD(10, 20),
+ horizontalSpacing: 1,
+ verticalSpacing: 7)[1] == new RectD(10, 227, 1200, 300),
+ "The configured horizontal gap must count toward the wrap threshold.");
Assert(
ImportLayout.ImageSize(1800, 1400) is { Width: 900, Height: 700 },
"Imported images should use the same 900 by 700 cap as a dropped image.");
@@ -787,6 +823,28 @@ await BoardArchive.SaveAsync(
"Unknown pen colors and sizes should snap to the pen default.");
var defaultSettings = AppSettingsSerializer.Parse(string.Empty);
+Assert(
+ defaultSettings.Import is { HorizontalSpacing: 32, VerticalSpacing: 32 } &&
+ AppSettingsSerializer.Parse("{ \"version\": 17 }").Import is
+ { HorizontalSpacing: 32, VerticalSpacing: 32 } &&
+ AppSettingsSerializer.Parse("{ \"import\": null }").Import is
+ { HorizontalSpacing: 32, VerticalSpacing: 32 },
+ "New, older, and null import settings should preserve the existing 32 px gaps.");
+Assert(
+ AppSettingsSerializer.Parse("{ \"import\": { \"horizontalSpacing\": 0 } }").Import is
+ { HorizontalSpacing: 0, VerticalSpacing: 32 },
+ "A missing direction should default independently without replacing a saved zero gap.");
+Assert(
+ AppSettingsSerializer.Parse(
+ "{ \"import\": { \"horizontalSpacing\": -10, \"verticalSpacing\": 10000 } }").Import is
+ { HorizontalSpacing: ImportSettings.MinimumSpacing, VerticalSpacing: ImportSettings.MaximumSpacing },
+ "Import spacing should clamp invalid saved values to the slider range.");
+Assert(
+ AppSettingsSerializer.Parse(AppSettingsSerializer.Format(new AppSettings
+ {
+ Import = new ImportSettings { HorizontalSpacing = double.NaN, VerticalSpacing = double.PositiveInfinity },
+ })).Import is { HorizontalSpacing: 32, VerticalSpacing: 32 },
+ "Non-finite spacing should fall back to the defaults before saving JSON.");
Assert(
defaultSettings.ToolbarPlacement == ToolbarPlacement.TopRight,
"Missing settings should default the toolbar to top-right.");
@@ -845,11 +903,15 @@ await BoardArchive.SaveAsync(
ToolbarPlacement = ToolbarPlacement.BottomCenter,
CalligraphyAccess = CalligraphyAccess.SizeRow,
Highlighter = new InkToolSettings { Argb = 0xFFF472B6, Thickness = 10 },
+ Import = new ImportSettings { HorizontalSpacing = 80, VerticalSpacing = 120 },
});
Assert(
formattedSettings.Contains("BottomCenter", StringComparison.Ordinal),
"Settings JSON should persist the toolbar placement name.");
var roundTripped = AppSettingsSerializer.Parse(formattedSettings);
+Assert(
+ roundTripped.Import is { HorizontalSpacing: 80, VerticalSpacing: 120 },
+ "Horizontal and vertical import spacing should persist independently.");
Assert(
roundTripped.ToolbarPlacement == ToolbarPlacement.BottomCenter &&
roundTripped.CalligraphyAccess == CalligraphyAccess.SizeRow &&
diff --git a/tests/SQLBI.Whiteboard.SmokeTests/PreferencesSmokeTests.cs b/tests/SQLBI.Whiteboard.SmokeTests/PreferencesSmokeTests.cs
new file mode 100644
index 0000000..b13f61e
--- /dev/null
+++ b/tests/SQLBI.Whiteboard.SmokeTests/PreferencesSmokeTests.cs
@@ -0,0 +1,143 @@
+using System.Windows;
+using System.Windows.Automation;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using SQLBI.Whiteboard.Core.Settings;
+
+namespace SQLBI.Whiteboard.SmokeTests;
+
+internal static class PreferencesSmokeTests
+{
+ public static void Run()
+ {
+ Exception? failure = null;
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ CheckImportSliders();
+ }
+ catch (Exception exception)
+ {
+ failure = exception;
+ }
+ });
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ thread.Join();
+ if (failure is not null)
+ {
+ throw new InvalidOperationException("Preferences smoke tests failed.", failure);
+ }
+ }
+
+ private static void CheckImportSliders()
+ {
+ // Load the real styles without starting the application or touching the user's settings.
+ var app = new App();
+ app.InitializeComponent();
+ var settings = new AppSettings();
+ var saved = string.Empty;
+ var changes = 0;
+ var window = new PreferencesWindow(settings, () =>
+ {
+ saved = AppSettingsSerializer.Format(settings);
+ changes++;
+ });
+ try
+ {
+ var horizontal = FindSlider(window, "Horizontal spacing");
+ var vertical = FindSlider(window, "Vertical spacing");
+ Assert(
+ horizontal.Value == 32 && vertical.Value == 32 && changes == 0,
+ "Opening Preferences should show the old gaps without applying any changes.");
+ Assert(
+ horizontal.Orientation == Orientation.Horizontal && vertical.Orientation == Orientation.Horizontal &&
+ horizontal.Minimum == 0 && horizontal.Maximum == 500 &&
+ vertical.Minimum == 0 && vertical.Maximum == 500 &&
+ horizontal.TickFrequency == 1 && vertical.TickFrequency == 1 &&
+ horizontal.IsSnapToTickEnabled && vertical.IsSnapToTickEnabled,
+ "Import should offer horizontal sliders with whole-pixel steps, including zero.");
+
+ horizontal.Value = 80;
+ Assert(
+ settings.Import is { HorizontalSpacing: 80, VerticalSpacing: 32 } && ValueLabel(horizontal) == "80 px",
+ "The horizontal slider should change only its gap and display pixels.");
+ vertical.Value = 120;
+ Assert(
+ changes == 2 && ValueLabel(vertical) == "120 px" &&
+ AppSettingsSerializer.Parse(saved).Import is { HorizontalSpacing: 80, VerticalSpacing: 120 },
+ "Both slider changes should reach the persistence callback with their independent values.");
+
+ var hold = FindSlider(window, "Trail duration");
+ var fade = FindSlider(window, "Fade duration");
+ Assert(
+ hold.Value == LaserSettings.DefaultHoldSeconds && hold.TickFrequency == 0.25 &&
+ hold.SmallChange == 0.25 && hold.LargeChange == 1 &&
+ fade.Value == LaserSettings.DefaultFadeSeconds && fade.TickFrequency == 0.05 &&
+ fade.SmallChange == 0.05 && fade.LargeChange == 0.5,
+ "Adding pixel sliders must not change the laser timing controls.");
+ hold.Value = 3;
+ fade.Value = 1;
+ Assert(
+ settings.Laser is { HoldSeconds: 3, FadeSeconds: 1 } &&
+ ValueLabel(hold) == "3 s" && ValueLabel(fade) == "1 s" &&
+ settings.Import is { HorizontalSpacing: 80, VerticalSpacing: 120 },
+ "Laser sliders should still update seconds without changing import spacing.");
+
+ var category = Descendants(window).OfType()
+ .Single(button => button.Content is "Import");
+ category.RaiseEvent(new RoutedEventArgs(ButtonBase.ClickEvent));
+ Assert(
+ Descendants(window).OfType().Count() == 2 &&
+ FindSlider(window, "Horizontal spacing").Value == 80 &&
+ FindSlider(window, "Vertical spacing").Value == 120 && changes == 4,
+ "The Import category should show only its sliders and retain values when rebuilt.");
+
+ var reopened = new PreferencesWindow(AppSettingsSerializer.Parse(saved), () =>
+ throw new InvalidOperationException("Reopening Preferences must not apply a change."));
+ try
+ {
+ Assert(
+ FindSlider(reopened, "Horizontal spacing").Value == 80 &&
+ FindSlider(reopened, "Vertical spacing").Value == 120,
+ "Saved spacing should be restored into the actual controls.");
+ }
+ finally
+ {
+ reopened.Close();
+ }
+ }
+ finally
+ {
+ window.Close();
+ app.Shutdown();
+ }
+ }
+
+ private static Slider FindSlider(DependencyObject root, string name) =>
+ Descendants(root).OfType().Single(slider => AutomationProperties.GetName(slider) == name);
+
+ private static string ValueLabel(Slider slider) =>
+ ((Grid)slider.Parent).Children.OfType().Single().Text;
+
+ private static IEnumerable Descendants(DependencyObject root)
+ {
+ foreach (var child in LogicalTreeHelper.GetChildren(root).OfType())
+ {
+ yield return child;
+ foreach (var descendant in Descendants(child))
+ {
+ yield return descendant;
+ }
+ }
+ }
+
+ private static void Assert(bool condition, string message)
+ {
+ if (!condition)
+ {
+ throw new InvalidOperationException(message);
+ }
+ }
+}
diff --git a/tests/SQLBI.Whiteboard.SmokeTests/Program.cs b/tests/SQLBI.Whiteboard.SmokeTests/Program.cs
index 2e1e1a0..efa9c5e 100644
--- a/tests/SQLBI.Whiteboard.SmokeTests/Program.cs
+++ b/tests/SQLBI.Whiteboard.SmokeTests/Program.cs
@@ -577,6 +577,8 @@ public function greet(string $name): string
}
}
+SQLBI.Whiteboard.SmokeTests.PreferencesSmokeTests.Run();
+
Console.WriteLine("SQLBI.Whiteboard smoke tests passed.");
static string ShortPathOf(string path)