From 26272e4acde70a2b2847d1a0efbe9dc68767bbca Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Wed, 2 Sep 2026 11:05:42 +0200 Subject: [PATCH 1/4] Offer the Eraser on the toolbar for pens without one Off by default: the Eraser stays off the toolbar unless finger or mouse drawing puts it there, because the pen's reverse end already erases and the button costs the toolbar a row. A pen whose back end is not an eraser has no way to reach the tool at all, and this is it. Pan is deliberately not part of the option. It is on the toolbar for devices with no touch and no Space to reach it with, which is not a property of the pen in hand. Co-Authored-By: Claude Opus 5 --- README.md | 7 ++++--- src/SQLBI.Whiteboard.Core/Settings/AppSettings.cs | 10 +++++++++- src/SQLBI.Whiteboard/MainWindow.xaml.cs | 14 +++++++++++--- src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs | 5 +++++ src/SQLBI.Whiteboard/SettingsCatalog.cs | 10 ++++++++++ tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs | 13 +++++++++++++ 6 files changed, 52 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e52309b..4188d8c 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ How the project is developed and shipped is documented separately: - Markdown `.wimport` recipes that build image and text containers from headings - An intentionally small floating toolbar - A File / Edit / View / Help tab strip. Click a tab for a one-row command strip over the canvas -- Preferences for the startup monitor, full-screen start, finger drawing, mouse drawing, the pen button, snippet format order, laser trail timing and weight, toolbar position and layout, and (except Store installs) a daily new-version check +- Preferences for the startup monitor, full-screen start, finger drawing, mouse drawing, the pen button, snippet format order, laser trail timing and weight, toolbar position and layout, keeping the Eraser on the toolbar for a pen that has no reverse end, and (except Store installs) a daily new-version check - About, with version and channel ## Build and run @@ -184,7 +184,7 @@ Use **Copy settings** after finding a useful combination so the exact values can | Ctrl + left mouse | Select/move/resize a container and return to the previous drawing tool — what the left button does on its own when Mouse drawing is off | | Double-click container | Center and fit the image, text, or LiveView to the canvas. With Mouse drawing on and an ink or eraser tool selected, hold Ctrl: two plain clicks are two strokes | | Double-click empty canvas | Center and fit all board content, or reset an empty board | -| Pen eraser | Erase complete strokes. The upper side button erases too: Windows reports it the same way as a pen turned round | +| Pen eraser | Erase complete strokes. The upper side button erases too: Windows reports it the same way as a pen turned round. A pen with neither reaches the Eraser through **Help → Preferences → Toolbar → Always show the Eraser** | | Pen barrel | Hold the barrel button for the action assigned in Preferences: Laser (default) or Straight line. Laser returns to the previous tool on release | | One finger | Pan. With Finger drawing on, uses the current tool instead | | Two fingers | Pan and pinch zoom. Cancels an in-progress finger stroke when Finger drawing is on | @@ -205,7 +205,7 @@ Use **Copy settings** after finding a useful combination so the exact values can | Delete | Delete the selected container and its linked strokes | | Alt+L | Laser pointer | | File / Edit / View / Help | Tab strip. Click a tab for a one-row command strip over the canvas. Click the canvas to hide it | -| Help > Preferences | Searchable settings: startup monitor, full screen, no-pen warning, finger drawing, mouse drawing, pen button, snippet format order, laser trail, toolbar, update checks | +| Help > Preferences | Searchable settings: startup monitor, full screen, no-pen warning, finger drawing, mouse drawing, pen button, snippet format order, laser trail, toolbar position and layout, always show the Eraser, update checks | | View > Bring to front / Send to back | Reorder the selected image, text, or LiveView (and its linked strokes) | | Help > About | Version, channel, license, the product site, and a download link when a newer release is known | | View > LiveView | Capture, freeze, disconnect, or reconnect a window or display | @@ -277,6 +277,7 @@ Test these on the target device before tuning stroke algorithms: 3. Rest a palm while drawing and verify the board does not pan. 4. Lift the pen, then immediately pan and pinch with touch. 5. Draw near all display edges and across the Windows display-scaling boundary, if multiple monitors use different scaling. +6. Turn on **Always show the Eraser**: the Eraser joins the toolbar and Pan does not, the tip then erases while it is selected, and turning the setting back off returns to the last drawing tool. Wacom driver settings can remap the barrel and eraser controls, so validate both Windows Ink mode and the intended application profile. diff --git a/src/SQLBI.Whiteboard.Core/Settings/AppSettings.cs b/src/SQLBI.Whiteboard.Core/Settings/AppSettings.cs index 6fbe7ad..97a3fc6 100644 --- a/src/SQLBI.Whiteboard.Core/Settings/AppSettings.cs +++ b/src/SQLBI.Whiteboard.Core/Settings/AppSettings.cs @@ -126,6 +126,14 @@ public sealed class AppSettings /// public bool SuggestMouseMode { get; set; } = true; + /// + /// Whether the Eraser button stays on the toolbar when nothing else puts it + /// there. Off by default because the pen's reverse end already erases and + /// the row costs the toolbar its height; on for the pens that have no + /// reverse end, which otherwise cannot reach the Eraser at all. + /// + public bool ShowEraserButton { get; set; } + public List SnippetFormatOrder { get; set; } = [.. TextLanguageIds.All]; public InkToolSettings Pen { get; set; } = InkToolSettings.From(InkPalettes.DefaultPen); @@ -162,7 +170,7 @@ public sealed class AppSettings public static class AppSettingsSerializer { - public const int CurrentVersion = 14; + public const int CurrentVersion = 15; private static readonly JsonSerializerOptions JsonOptions = new() { diff --git a/src/SQLBI.Whiteboard/MainWindow.xaml.cs b/src/SQLBI.Whiteboard/MainWindow.xaml.cs index e2420f5..0a0d77e 100644 --- a/src/SQLBI.Whiteboard/MainWindow.xaml.cs +++ b/src/SQLBI.Whiteboard/MainWindow.xaml.cs @@ -1642,7 +1642,8 @@ private static bool HasStylusDigitizer() // Finger drawing and mouse drawing are separate settings that need the same // two toolbar buttons, for the same reason: erasing is the pen's reverse end // and panning is touch or Space, and a device with neither has nowhere else - // to reach them. + // to reach them. The Eraser has a third reason of its own - a pen whose back + // end is not an eraser - so it can be asked for on its own, and Pan cannot. private void ApplyPointerModes() { var fingerInk = IsFingerModeEffective; @@ -1654,12 +1655,19 @@ private void ApplyPointerModes() InkSurface.SetAllowTouchInk(fingerInk); var extraTools = fingerInk || IsMouseModeEffective; + var eraserTool = extraTools || _settings.ShowEraserButton; if (ExtraToolsRow is not null) { - ExtraToolsRow.Visibility = extraTools ? Visibility.Visible : Visibility.Collapsed; + ExtraToolsRow.Visibility = eraserTool ? Visibility.Visible : Visibility.Collapsed; } - if (!extraTools && _activeTool is BoardTool.Eraser or BoardTool.Pan) + if (PanToolButton is not null) + { + PanToolButton.Visibility = extraTools ? Visibility.Visible : Visibility.Collapsed; + } + + if ((!eraserTool && _activeTool is BoardTool.Eraser) || + (!extraTools && _activeTool is BoardTool.Pan)) { SetActiveTool(_lastDrawingTool); } diff --git a/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs b/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs index a2631f0..618ce62 100644 --- a/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs +++ b/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs @@ -729,6 +729,7 @@ private ToggleButton CreateSwitch(SettingDescriptor setting) SettingsCatalog.Ids.StartFullScreen => _settings.StartFullScreen, SettingsCatalog.Ids.WarnWhenNoDigitizer => _settings.WarnWhenNoDigitizer, SettingsCatalog.Ids.SuggestMouseMode => _settings.SuggestMouseMode, + SettingsCatalog.Ids.ShowEraserButton => _settings.ShowEraserButton, SettingsCatalog.Ids.CheckForUpdates => _settings.CheckForUpdates, _ => false, }, @@ -915,6 +916,10 @@ private void SetBoolean(SettingDescriptor setting, bool value) { _settings.SuggestMouseMode = value; } + else if (setting.Id == SettingsCatalog.Ids.ShowEraserButton) + { + _settings.ShowEraserButton = value; + } else if (setting.Id == SettingsCatalog.Ids.CheckForUpdates) { _settings.CheckForUpdates = value; diff --git a/src/SQLBI.Whiteboard/SettingsCatalog.cs b/src/SQLBI.Whiteboard/SettingsCatalog.cs index 918936a..57e6642 100644 --- a/src/SQLBI.Whiteboard/SettingsCatalog.cs +++ b/src/SQLBI.Whiteboard/SettingsCatalog.cs @@ -82,6 +82,7 @@ public static class Ids public const string LaserTrailWeight = "laser.trailWeight"; public const string ToolbarPlacement = "toolbar.placement"; public const string ToolbarLayout = "toolbar.layout"; + public const string ShowEraserButton = "toolbar.eraserButton"; public const string WarnWhenNoDigitizer = "startup.noDigitizerNotice"; public const string FingerMode = "input.fingerMode"; public const string MouseMode = "input.mouseMode"; @@ -275,6 +276,15 @@ public static class Ids ], }, new() + { + Id = Ids.ShowEraserButton, + Category = Toolbar, + Title = "Always show the Eraser", + Description = "Off, the Eraser is on the toolbar only when finger or mouse drawing puts it there, because the pen's reverse end already erases and the button costs the toolbar a row. On, it stays there for the pen too, which is the only way to reach the Eraser with a pen that has no reverse end. Pan is unaffected: it stays on the toolbar only when something else needs it.", + Keywords = ["eraser", "toolbar", "button", "pen", "rubber", "erase", "no eraser"], + Editor = SettingEditorKind.BooleanSwitch, + }, + new() { Id = Ids.CheckForUpdates, Category = Updates, diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs index f2b66b1..b41d971 100644 --- a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs +++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs @@ -898,6 +898,19 @@ defaultSettings.StartupMonitorName is null && Assert( !offerRoundTrip.SuggestMouseMode, "Settings JSON should round-trip the mouse drawing offer."); +// The Eraser button costs the toolbar a row, so it stays off until asked for - +// including for settings written before it could be asked for. +Assert( + !defaultSettings.ShowEraserButton, + "Missing settings should leave the Eraser off the toolbar."); +Assert( + !settingsFromVersion12.ShowEraserButton, + "Settings saved before the Eraser button existed should not grow a toolbar row."); +var eraserButtonRoundTrip = AppSettingsSerializer.Parse( + AppSettingsSerializer.Format(new AppSettings { ShowEraserButton = true })); +Assert( + eraserButtonRoundTrip.ShowEraserButton, + "Settings JSON should round-trip the always-show-the-Eraser choice."); Assert( defaultSettings.SnippetFormatOrder is ["plain", "dax", "sqlserver"], "Missing settings should keep Plain text first so paste stays plain text."); From 800aa5c211eac1a25aa0ebb64776420ef2809a3c Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Wed, 2 Sep 2026 11:16:45 +0200 Subject: [PATCH 2/4] Keep the Eraser in the bar in the compact toolbar layouts A second row holding one button doubles the compact toolbar's height to say very little, so in Chevron and Icons-beside-the-size-chips the Eraser joins the row of tools instead. The dual palette is stacked groups already, so there a row beneath reads as one more group and the Eraser stays where it was. The button is one instance moved between the two hosts rather than two that have to be kept in step, and the layout setting moves it. Bumps VersionPrefix to 1.2.2. Co-Authored-By: Claude Opus 5 --- Directory.Build.props | 2 +- README.md | 2 +- TODO.md | 5 +- src/SQLBI.Whiteboard/MainWindow.xaml.cs | 79 +++++++++++++++++++++---- src/SQLBI.Whiteboard/SettingsCatalog.cs | 2 +- 5 files changed, 72 insertions(+), 18 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 849785e..d378e12 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.2.1 + 1.2.2 latest enable enable diff --git a/README.md b/README.md index 4188d8c..22ec598 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ Test these on the target device before tuning stroke algorithms: 3. Rest a palm while drawing and verify the board does not pan. 4. Lift the pen, then immediately pan and pinch with touch. 5. Draw near all display edges and across the Windows display-scaling boundary, if multiple monitors use different scaling. -6. Turn on **Always show the Eraser**: the Eraser joins the toolbar and Pan does not, the tip then erases while it is selected, and turning the setting back off returns to the last drawing tool. +6. Turn on **Always show the Eraser**: it joins the row of tools, and moves to its own row under the palette when the layout is Dual palette. Pan does not appear either way, the tip erases while the Eraser is selected, and turning the setting back off returns to the last drawing tool. Wacom driver settings can remap the barrel and eraser controls, so validate both Windows Ink mode and the intended application profile. diff --git a/TODO.md b/TODO.md index 0d8e590..1af94bd 100644 --- a/TODO.md +++ b/TODO.md @@ -16,7 +16,7 @@ The delivery chain works end to end: a merge to `main` builds, signs, and publis pre-release to GitHub Releases, and one approval promotes that same build to a release. reads its download links from the release manifest deployed beside it and needs no edit per release. The current product version is `VersionPrefix` in `Directory.Build.props` -(1.2.1). Identity version for the Store package is `VersionPrefix.0` (`1.2.1.0`). +(1.2.2). Identity version for the Store package is `VersionPrefix.0` (`1.2.2.0`). Declaring that number is decision 20 in [docs/decisions.md](docs/decisions.md). What 1.0 was waiting on shipped during 0.9.x: Preferences, `.wimport`, Explorer and VS Code @@ -27,7 +27,8 @@ No numbered work remains. 1.2.0 answered [discussion 78](https://github.com/sql-bi/SQLBI-Whiteboard/discussions/78) with Mouse drawing — decision 23, with the alternatives kept in [docs/mouse-mode.md](docs/mouse-mode.md) — and 1.2.1 made it discoverable from the -toolbar, decision 24. The video teaser is recorded and served from the landing page +toolbar, decision 24. 1.2.2 puts the Eraser within reach of a pen whose back end is not +one, as an option that is off by default. The video teaser is recorded and served from the landing page itself as `site/teaser-av1.mp4` / `site/teaser-h264.mp4` — the Vimeo-embed plan was reversed, see decision 19 in [docs/decisions.md](docs/decisions.md); the production script and staging assets are in `docs/teaser/`. The release manifests and the Store diff --git a/src/SQLBI.Whiteboard/MainWindow.xaml.cs b/src/SQLBI.Whiteboard/MainWindow.xaml.cs index 0a0d77e..0fe425a 100644 --- a/src/SQLBI.Whiteboard/MainWindow.xaml.cs +++ b/src/SQLBI.Whiteboard/MainWindow.xaml.cs @@ -1639,11 +1639,6 @@ private static bool HasStylusDigitizer() return false; } - // Finger drawing and mouse drawing are separate settings that need the same - // two toolbar buttons, for the same reason: erasing is the pen's reverse end - // and panning is touch or Space, and a device with neither has nowhere else - // to reach them. The Eraser has a third reason of its own - a pen whose back - // end is not an eraser - so it can be asked for on its own, and Pan cannot. private void ApplyPointerModes() { var fingerInk = IsFingerModeEffective; @@ -1653,12 +1648,23 @@ private void ApplyPointerModes() } InkSurface.SetAllowTouchInk(fingerInk); + ApplyExtraTools(); + } - var extraTools = fingerInk || IsMouseModeEffective; + // Finger drawing and mouse drawing are separate settings that need the same + // two toolbar buttons, for the same reason: erasing is the pen's reverse end + // and panning is touch or Space, and a device with neither has nowhere else + // to reach them. The Eraser has a third reason of its own - a pen whose back + // end is not an eraser - so it can be asked for on its own, and Pan cannot. + private void ApplyExtraTools() + { + var extraTools = IsFingerModeEffective || IsMouseModeEffective; var eraserTool = extraTools || _settings.ShowEraserButton; - if (ExtraToolsRow is not null) + var dual = IsDualLayout; + PlaceEraserButton(dual); + if (EraserToolButton is not null) { - ExtraToolsRow.Visibility = eraserTool ? Visibility.Visible : Visibility.Collapsed; + EraserToolButton.Visibility = eraserTool ? Visibility.Visible : Visibility.Collapsed; } if (PanToolButton is not null) @@ -1666,6 +1672,14 @@ private void ApplyPointerModes() PanToolButton.Visibility = extraTools ? Visibility.Visible : Visibility.Collapsed; } + if (ExtraToolsRow is not null) + { + // The row is Pan's alone once the Eraser has moved up into the bar, + // so it goes away with Pan rather than with either button. + var wanted = dual ? eraserTool : extraTools; + ExtraToolsRow.Visibility = wanted ? Visibility.Visible : Visibility.Collapsed; + } + if ((!eraserTool && _activeTool is BoardTool.Eraser) || (!extraTools && _activeTool is BoardTool.Pan)) { @@ -1673,6 +1687,40 @@ private void ApplyPointerModes() } } + // Where the Eraser sits is a question about the layout, not about why it is + // there. The dual palette is stacked groups already, so a row beneath it + // reads as one more group; the compact bar is a single line of tools, and a + // second line holding one button doubles the toolbar's height to say very + // little. + private void PlaceEraserButton(bool dual) + { + if (EraserToolButton is null || ToolButtonsRow is null || ExtraToolsRow is null) + { + return; + } + + Panel host = dual ? ExtraToolsRow : ToolButtonsRow; + if (ReferenceEquals(EraserToolButton.Parent, host)) + { + return; + } + + if (EraserToolButton.Parent is Panel previous) + { + previous.Children.Remove(EraserToolButton); + } + + if (dual) + { + // Ahead of Pan, which is the order the two have always been in. + host.Children.Insert(0, EraserToolButton); + } + else + { + host.Children.Add(EraserToolButton); + } + } + private bool TryBeginFingerTool(StylusDownEventArgs e) { InkSurface.RegisterTouchTablet(e.StylusDevice.TabletDevice.Id); @@ -2920,14 +2968,19 @@ private void ApplyCalligraphyAccess() { SetInkOptionsOpen(false); RebuildDualPalette(); - return; } - - ApplyInkOptionsWidth(); - if (_isInkOptionsOpen) + else { - RebuildInkOptions(); + ApplyInkOptionsWidth(); + if (_isInkOptionsOpen) + { + RebuildInkOptions(); + } } + + // The Eraser sits in the bar in the compact layouts and under it in the + // dual palette, so changing the layout moves it. + ApplyExtraTools(); } private void ApplyInkOptionsWidth() diff --git a/src/SQLBI.Whiteboard/SettingsCatalog.cs b/src/SQLBI.Whiteboard/SettingsCatalog.cs index 57e6642..81e90ec 100644 --- a/src/SQLBI.Whiteboard/SettingsCatalog.cs +++ b/src/SQLBI.Whiteboard/SettingsCatalog.cs @@ -280,7 +280,7 @@ public static class Ids Id = Ids.ShowEraserButton, Category = Toolbar, Title = "Always show the Eraser", - Description = "Off, the Eraser is on the toolbar only when finger or mouse drawing puts it there, because the pen's reverse end already erases and the button costs the toolbar a row. On, it stays there for the pen too, which is the only way to reach the Eraser with a pen that has no reverse end. Pan is unaffected: it stays on the toolbar only when something else needs it.", + Description = "Off, the Eraser is on the toolbar only when finger or mouse drawing puts it there, because the pen's reverse end already erases. On, it stays there for the pen too, which is the only way to reach the Eraser with a pen that has no reverse end. It joins the row of tools in the compact layouts and sits under the palette in Dual palette. Pan is unaffected: it stays on the toolbar only when something else needs it.", Keywords = ["eraser", "toolbar", "button", "pen", "rubber", "erase", "no eraser"], Editor = SettingEditorKind.BooleanSwitch, }, From 4b714fde41fd554e73b28de6a0de88a966d8b51f Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Wed, 2 Sep 2026 12:14:30 +0200 Subject: [PATCH 3/4] Say each setting in one line and keep the reasoning behind a chevron Preferences had grown into a wall of prose: the longest description ran to six wrapped lines, and a category had to be read to be skipped. Every setting now carries a one-line summary that is always on the row, and the defaults, reasoning and consequences move behind a chevron. A setting whose summary is the whole story keeps an empty description and gets no chevron. Not a tooltip: this application is used with a pen and a finger, neither of which hovers, and on a Cintiq the reasoning would simply be gone. The chevron is a real button because pressing is the one gesture every input here can perform. A search that matches only the hidden prose opens the row it matched, so a hit never arrives looking like a mistake. The chevrons take a fixed column right of the editors. Between title and editor they landed somewhere different on every row, and they took the width the summary needed to stay on one line. The window goes to 820 so the widest combo cannot starve that column either. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- docs/decisions.md | 30 +++++++ src/SQLBI.Whiteboard/PreferencesWindow.xaml | 4 +- .../PreferencesWindow.xaml.cs | 80 ++++++++++++++----- src/SQLBI.Whiteboard/SettingsCatalog.cs | 54 ++++++++++++- src/SQLBI.Whiteboard/Themes/Settings.xaml | 79 +++++++++++++++++- 6 files changed, 223 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 22ec598..fbe80b2 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ Use **Copy settings** after finding a useful combination so the exact values can | Delete | Delete the selected container and its linked strokes | | Alt+L | Laser pointer | | File / Edit / View / Help | Tab strip. Click a tab for a one-row command strip over the canvas. Click the canvas to hide it | -| Help > Preferences | Searchable settings: startup monitor, full screen, no-pen warning, finger drawing, mouse drawing, pen button, snippet format order, laser trail, toolbar position and layout, always show the Eraser, update checks | +| Help > Preferences | Searchable settings: startup monitor, full screen, no-pen warning, finger drawing, mouse drawing, pen button, snippet format order, laser trail, toolbar position and layout, always show the Eraser, update checks. Each setting is one line; its chevron opens the reasoning behind it, and a search that matches only that text opens the row for you | | View > Bring to front / Send to back | Reorder the selected image, text, or LiveView (and its linked strokes) | | Help > About | Version, channel, license, the product site, and a download link when a newer release is known | | View > LiveView | Capture, freeze, disconnect, or reconnect a window or display | diff --git a/docs/decisions.md b/docs/decisions.md index 3273d1f..8acb593 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -512,6 +512,36 @@ can only have appeared on a machine where the automatic default already decided --- +## 25. Preferences says one line and keeps the rest behind a chevron + +**Implemented** in 1.2.2. Every setting carries two texts rather than one: a `Summary` of a +single line that is always on the row, and a `Description` — the defaults, the reasoning, +the consequences — that appears only when the row's chevron is pressed. A setting whose +summary is the whole story leaves `Description` empty and gets no chevron at all. + +The dialog had grown to where the longest description ran to six wrapped lines, and a +category was a wall of prose that had to be read to be skipped. The reasoning is worth +keeping — it is the difference between a setting someone can decide about and one they +guess at — so the answer was to stop showing it unasked rather than to delete it. + +Two parts of this are choices a later change could quietly undo: + +- **Not a tooltip.** A tooltip is the obvious way to hide text and the wrong one here. This + application is used with a pen and a finger, and neither hovers: on a Cintiq the + reasoning would simply be gone. The chevron is a real button because pressing is the one + gesture every input this application supports can perform. +- **A search that matches only the hidden text opens the row.** Otherwise a hit arrives + looking like a mistake — a row with nothing on it containing what was typed. Matching on + the title or the summary leaves the row shut, because the reason it is there is already + visible. + +The chevrons occupy a fixed-width column of their own, to the right of the editors. Placing +them between the title and the editor was tried and abandoned: editors range from a switch +to a wide combo, so the chevron landed at a different place on every row, and the column it +was borrowing from was exactly the one the summary needed in order to stay on one line. + +--- + ## Open questions - arm64 is not built; add it if Surface devices matter for a pen application. diff --git a/src/SQLBI.Whiteboard/PreferencesWindow.xaml b/src/SQLBI.Whiteboard/PreferencesWindow.xaml index 9a6db8c..7935cc5 100644 --- a/src/SQLBI.Whiteboard/PreferencesWindow.xaml +++ b/src/SQLBI.Whiteboard/PreferencesWindow.xaml @@ -2,9 +2,9 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Preferences" - Width="720" + Width="820" Height="560" - MinWidth="560" + MinWidth="640" MinHeight="400" WindowStartupLocation="CenterOwner" ResizeMode="CanResize" diff --git a/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs b/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs index 618ce62..59ca493 100644 --- a/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs +++ b/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Windows; +using System.Windows.Automation; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Input; @@ -12,6 +13,8 @@ namespace SQLBI.Whiteboard; public partial class PreferencesWindow : Window { + private const double DisclosureColumnWidth = 32; + private readonly AppSettings _settings; private readonly Action _applied; private readonly IReadOnlyList _monitors; @@ -63,7 +66,7 @@ private void Rebuild() var visible = SettingsCatalog.Filter(query, _selectedCategory); RebuildCategories(visibleCategories); - RebuildSettings(visible); + RebuildSettings(visible, query); } private void RebuildCategories(IReadOnlyList categories) @@ -94,7 +97,7 @@ private void CategoryButton_Click(object sender, RoutedEventArgs e) Rebuild(); } - private void RebuildSettings(IReadOnlyList settings) + private void RebuildSettings(IReadOnlyList settings, string? query) { SettingsHost.Children.Clear(); var empty = settings.Count == 0; @@ -129,7 +132,8 @@ private void RebuildSettings(IReadOnlyList settings) lastCategory = setting.Category; } - SettingsHost.Children.Add(CreateRow(setting)); + SettingsHost.Children.Add( + CreateRow(setting, SettingsCatalog.MatchesDescriptionOnly(setting, query))); } } finally @@ -138,9 +142,12 @@ private void RebuildSettings(IReadOnlyList settings) } } - private Border CreateRow(SettingDescriptor setting) + // The row's own words: the title, the one line always under it, and the + // prose that appears only when the chevron is asked for it. + private (StackPanel Copy, ToggleButton? Disclosure) CreateCopy( + SettingDescriptor setting, + bool expand) { - var editor = CreateEditor(setting); var copy = new StackPanel(); copy.Children.Add(new TextBlock { @@ -148,18 +155,61 @@ private Border CreateRow(SettingDescriptor setting) Text = setting.Title, }); copy.Children.Add(new TextBlock + { + Style = (Style)FindResource("SettingsSummary"), + Text = setting.Summary, + }); + + // A setting whose summary is the whole story has nothing to disclose, + // and a chevron on it would promise something that is not there. + if (setting.Description.Length == 0) + { + return (copy, null); + } + + var detail = new TextBlock { Style = (Style)FindResource("SettingsDescription"), Text = setting.Description, - }); + Visibility = expand ? Visibility.Visible : Visibility.Collapsed, + }; + copy.Children.Add(detail); + + var disclosure = new ToggleButton + { + Style = (Style)FindResource("SettingsDisclosure"), + IsChecked = expand, + ToolTip = $"More about {setting.Title}", + }; + AutomationProperties.SetName(disclosure, $"More about {setting.Title}"); + disclosure.Checked += (_, _) => detail.Visibility = Visibility.Visible; + disclosure.Unchecked += (_, _) => detail.Visibility = Visibility.Collapsed; + return (copy, disclosure); + } + + private Border CreateRow(SettingDescriptor setting, bool expand) + { + var editor = CreateEditor(setting); + var (copy, disclosure) = CreateCopy(setting, expand); var body = new Grid(); + body.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + body.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + body.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + body.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + // The chevron's column is reserved whether or not this row has one, so + // that the chevrons line up and, more importantly, so do the editors. + body.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(DisclosureColumnWidth) }); + body.Children.Add(copy); + if (disclosure is not null) + { + Grid.SetColumn(disclosure, 2); + body.Children.Add(disclosure); + } + if (setting.Editor == SettingEditorKind.DoubleRange) { - body.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - body.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - body.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - body.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); var slider = (Slider)editor; var value = new TextBlock { @@ -169,8 +219,7 @@ private Border CreateRow(SettingDescriptor setting) slider.ValueChanged += (_, _) => value.Text = FormatSeconds(slider.Value); Grid.SetColumn(value, 1); Grid.SetRow(slider, 1); - Grid.SetColumnSpan(slider, 2); - body.Children.Add(copy); + Grid.SetColumnSpan(slider, 3); body.Children.Add(value); body.Children.Add(slider); } @@ -181,21 +230,16 @@ SettingEditorKind.PenButtonChoice or SettingEditorKind.ToolbarPlacementChoice or SettingEditorKind.ToolbarLayoutChoice) { - body.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - body.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); Grid.SetRow(editor, 1); + Grid.SetColumnSpan(editor, 3); editor.Margin = new Thickness(0, 8, 0, 0); - body.Children.Add(copy); body.Children.Add(editor); } else { - body.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - body.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); Grid.SetColumn(editor, 1); editor.Margin = new Thickness(16, 0, 0, 0); editor.VerticalAlignment = VerticalAlignment.Center; - body.Children.Add(copy); body.Children.Add(editor); } diff --git a/src/SQLBI.Whiteboard/SettingsCatalog.cs b/src/SQLBI.Whiteboard/SettingsCatalog.cs index 81e90ec..f51564e 100644 --- a/src/SQLBI.Whiteboard/SettingsCatalog.cs +++ b/src/SQLBI.Whiteboard/SettingsCatalog.cs @@ -54,7 +54,20 @@ internal sealed class SettingDescriptor public required string Title { get; init; } - public required string Description { get; init; } + /// + /// The one line that is always on the row, under the title. It has to say + /// what the setting is for in a single line at the dialog's width, because + /// nothing else about the setting is visible until someone asks for it. + /// + public required string Summary { get; init; } + + /// + /// The reasoning, the defaults, and the consequences - everything that will + /// not fit on one line, shown only when the row is expanded. Empty for a + /// setting whose summary is the whole story, and such a row has no + /// disclosure at all. + /// + public string Description { get; init; } = string.Empty; public required string[] Keywords { get; init; } @@ -108,7 +121,7 @@ public static class Ids Id = Ids.StartupMonitor, Category = Startup, Title = "Open on", - Description = "Which display the window uses at launch.", + Summary = "Which display the window uses at launch", Keywords = ["monitor", "display", "cintiq", "wacom", "screen"], Editor = SettingEditorKind.MonitorChoice, }, @@ -117,6 +130,7 @@ public static class Ids Id = Ids.StartFullScreen, Category = Startup, Title = "Start full screen", + Summary = "Fill the monitor and hide the chrome at launch", Description = "Fill the current monitor and hide the title and tabs the next time the application starts. F11 still toggles this session. Ctrl+F11 hides chrome without filling the monitor.", Keywords = ["fullscreen", "full screen", "f11", "maximize"], Editor = SettingEditorKind.BooleanSwitch, @@ -126,6 +140,7 @@ public static class Ids Id = Ids.WarnWhenNoDigitizer, Category = Startup, Title = "Warn when there is nothing to draw with", + Summary = "Say so at startup when Windows reports no digitizer", Description = "Say so at startup when Windows reports neither a pen tablet nor a touchscreen, and describe what Mouse drawing does and does not give you in place of a pen. What Windows reports is a list of digitizers rather than what is plugged in, so a pen that has never been brought into range can be missing from it.", Keywords = ["pen", "touch", "touchscreen", "digitizer", "tablet", "mouse", "warning", "notice", "startup"], Editor = SettingEditorKind.BooleanSwitch, @@ -135,6 +150,7 @@ public static class Ids Id = Ids.FingerMode, Category = Input, Title = "Finger drawing", + Summary = "Whether one finger draws or pans", Description = "New installs default to When no pen is detected. Off keeps one-finger pan. On makes one finger use the current tool; two fingers still pan and pinch-zoom, and Eraser and Pan appear on the toolbar. \"When no pen is detected\" uses the digitizer list Windows reports, which is not the same as a pen being in the room.", Keywords = ["finger", "touch", "pen", "draw", "tablet", "stylus", "digitizer"], Editor = SettingEditorKind.EnumChoice, @@ -150,6 +166,7 @@ public static class Ids Id = Ids.MouseMode, Category = Input, Title = "Mouse drawing", + Summary = "What the left mouse button does", Description = "New installs default to When there is no pen or touchscreen. Off keeps the left button for moving containers, which is what it has always done. On makes the left button use the current tool, and puts Eraser and Pan on the toolbar; Ctrl and the left button then move and resize a container, and Ctrl with a double-click frames one. A mouse reports no pressure, so only Calligraphy still varies its width, and nothing about the pen changes when this is on.", Keywords = ["mouse", "draw", "drawing", "pointer", "no pen", "digitizer", "left button", "ctrl"], Editor = SettingEditorKind.EnumChoice, @@ -165,6 +182,7 @@ public static class Ids Id = Ids.SuggestMouseMode, Category = Input, Title = "Offer mouse drawing when the mouse picks a tool", + Summary = "Ask once a session while mouse drawing is off", Description = "With Mouse drawing off, choosing a tool from the toolbar with the mouse offers to turn it on. Asked once a session, and not again once the offer has been declined for good.", Keywords = ["mouse", "offer", "prompt", "dialog", "toolbar", "suggest", "ask"], Editor = SettingEditorKind.BooleanSwitch, @@ -174,6 +192,7 @@ public static class Ids Id = Ids.PenButton, Category = Input, Title = "Pen button", + Summary = "What holding the barrel button does", Description = "The barrel button on the side of the pen. Hold it for the assigned action: Laser lasts only while the button is down, Straight line is the same constraint as holding Shift. The reverse end of the pen always erases, and so does the upper button, because Windows reports the two the same way.", Keywords = ["pen", "barrel", "button", "laser", "straight", "line", "shift", "stylus", "eraser", "wacom", "cintiq"], Editor = SettingEditorKind.PenButtonChoice, @@ -188,6 +207,7 @@ public static class Ids Id = Ids.SnippetFormatOrder, Category = Input, Title = "Snippet format order", + Summary = "Which language pasted text is tried as first", Description = "Paste tries formats from top to bottom and uses the first that accepts the text. Plain text always accepts, so putting it first keeps every paste as plain text. Recognized file extensions (.dax, .sql, .txt) keep their language.", Keywords = ["snippet", "language", "dax", "sql", "paste", "format", "text", "order"], Editor = SettingEditorKind.OrderedList, @@ -197,7 +217,7 @@ public static class Ids Id = Ids.LaserHoldSeconds, Category = Laser, Title = "Trail duration", - Description = "How long the laser stays fully visible after you lift.", + Summary = "How long the laser stays fully visible after you lift", Keywords = ["laser", "decay", "hold", "trail", "duration"], Editor = SettingEditorKind.DoubleRange, Minimum = LaserSettings.MinimumHoldSeconds, @@ -208,7 +228,7 @@ public static class Ids Id = Ids.LaserFadeSeconds, Category = Laser, Title = "Fade duration", - Description = "How long the trail takes to disappear after the hold.", + Summary = "How long the trail takes to disappear after the hold", Keywords = ["laser", "fade", "decay", "trail"], Editor = SettingEditorKind.DoubleRange, Minimum = LaserSettings.MinimumFadeSeconds, @@ -219,6 +239,7 @@ public static class Ids Id = Ids.LaserHoldMode, Category = Laser, Title = "Hold", + Summary = "Whether a new stroke keeps the previous trail alive", Description = "Whether a new stroke keeps the previous trail alive or starts its own timer.", Keywords = ["laser", "hold", "shared", "stroke"], Editor = SettingEditorKind.EnumChoice, @@ -233,6 +254,7 @@ public static class Ids Id = Ids.LaserTrailWeight, Category = Laser, Title = "Trail weight", + Summary = "How much a light touch is thinned out", Description = "A pen reports little pressure on a quick tap. Each option shows that tap above a firm stroke: the firm stroke never changes, only how much the light one is thinned out.", Keywords = ["laser", "weight", "thickness", "width", "pressure", "trail"], Editor = SettingEditorKind.LaserWeightChoice, @@ -248,6 +270,7 @@ public static class Ids Id = Ids.ToolbarPlacement, Category = Toolbar, Title = "Position", + Summary = "Which corner the toolbar sits in", Description = "Top right keeps the toolbar under a typical presenter picture-in-picture during recording.", Keywords = ["toolbar", "position", "placement", "pip"], Editor = SettingEditorKind.ToolbarPlacementChoice, @@ -265,6 +288,7 @@ public static class Ids Id = Ids.ToolbarLayout, Category = Toolbar, Title = "Layout", + Summary = "How the colors and sizes are arranged", Description = "Dual palette keeps both tools’ colors and sizes visible. The other layouts use a compact bar and a single-tool panel.", Keywords = ["toolbar", "layout", "calligraphy", "palette", "chevron"], Editor = SettingEditorKind.ToolbarLayoutChoice, @@ -280,6 +304,7 @@ public static class Ids Id = Ids.ShowEraserButton, Category = Toolbar, Title = "Always show the Eraser", + Summary = "Keep it on the toolbar for a pen without one", Description = "Off, the Eraser is on the toolbar only when finger or mouse drawing puts it there, because the pen's reverse end already erases. On, it stays there for the pen too, which is the only way to reach the Eraser with a pen that has no reverse end. It joins the row of tools in the compact layouts and sits under the palette in Dual palette. Pan is unaffected: it stays on the toolbar only when something else needs it.", Keywords = ["eraser", "toolbar", "button", "pen", "rubber", "erase", "no eraser"], Editor = SettingEditorKind.BooleanSwitch, @@ -289,6 +314,7 @@ public static class Ids Id = Ids.CheckForUpdates, Category = Updates, Title = "Check for new versions", + Summary = "Ask GitHub once a day whether a newer build exists", Description = "Once a day the application asks GitHub whether a newer released build exists. It does not send a machine identifier. Microsoft Store installs are updated by the Store and never make this request.", Keywords = ["update", "version", "github", "download", "release"], Editor = SettingEditorKind.BooleanSwitch, @@ -330,11 +356,31 @@ public static bool Matches(SettingDescriptor setting, string query) var term = query.Trim(); return Contains(setting.Title, term) || + Contains(setting.Summary, term) || Contains(setting.Description, term) || Contains(setting.Category, term) || setting.Keywords.Any(keyword => Contains(keyword, term)); } + /// + /// Whether a search found this setting only in the prose behind its + /// disclosure. Such a row opens on arrival, because a hit with nothing on + /// it to explain itself reads as a fault in the search. + /// + public static bool MatchesDescriptionOnly(SettingDescriptor setting, string? query) + { + ArgumentNullException.ThrowIfNull(setting); + if (string.IsNullOrWhiteSpace(query)) + { + return false; + } + + var term = query.Trim(); + return Contains(setting.Description, term) && + !Contains(setting.Title, term) && + !Contains(setting.Summary, term); + } + private static bool Contains(string value, string term) => value.Contains(term, StringComparison.OrdinalIgnoreCase); } diff --git a/src/SQLBI.Whiteboard/Themes/Settings.xaml b/src/SQLBI.Whiteboard/Themes/Settings.xaml index 7adf003..572d94f 100644 --- a/src/SQLBI.Whiteboard/Themes/Settings.xaml +++ b/src/SQLBI.Whiteboard/Themes/Settings.xaml @@ -166,6 +166,24 @@ Value="Wrap" /> + + + + + + + + M0,0 L8,8 M8,0 L0,8 + + +