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.2latestenableenable
diff --git a/README.md b/README.md
index e52309b..7a06159 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. Each setting is one line; its chevron opens the reasoning behind it. Search marks what it matched, and marks the chevron when the match is in the text behind it |
| 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**: 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/docs/decisions.md b/docs/decisions.md
index 3273d1f..f8350d0 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -512,6 +512,49 @@ 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.
+- **Search marks what it matched.** Every hit is highlighted where it lies — in the title,
+ in the summary, in the prose once that is open. A hit lying only in the prose has nothing
+ on the row to mark, so the chevron is marked instead: it says the answer is in here
+ without opening a row under the hands of someone still typing. Expanding those rows
+ automatically was built first and then removed, because the list jumps on every
+ keystroke. A row matched only by a keyword marks nothing, deliberately — the word is not
+ in the prose either, and a marked chevron would promise text that is not there.
+
+The chevron sits under the title and in front of the summary. The right-hand edge of the
+row was tried first and cannot have it: editors range from a switch to a wide combo, so the
+chevron landed somewhere different on every row; it took the width the summary needed to
+stay on one line; and on **Snippet format order** it came to rest in the same column as
+that editor's own reordering chevrons, where it read as one of them.
+
+**Always show the Eraser is drawn rather than switched.** It is one boolean and a switch was
+the ordinary answer, but the question it asks is where a button appears, so it is offered as
+two pictures of the toolbar — identical but for the Eraser, and redrawn to match whichever
+arrangement **Layout** has chosen, since that setting decides whether the Eraser joins the
+bar or takes a row beneath it. The Eraser is in the accent color because the difference
+between the two pictures is the whole question, and the Off picture reserves its space
+rather than closing up, so that turning it on adds the Eraser instead of moving everything
+else.
+
+---
+
## Open questions
- arm64 is not built; add it if Surface devices matter for a pen application.
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..0fe425a 100644
--- a/src/SQLBI.Whiteboard/MainWindow.xaml.cs
+++ b/src/SQLBI.Whiteboard/MainWindow.xaml.cs
@@ -1639,10 +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.
private void ApplyPointerModes()
{
var fingerInk = IsFingerModeEffective;
@@ -1652,19 +1648,79 @@ private void ApplyPointerModes()
}
InkSurface.SetAllowTouchInk(fingerInk);
+ ApplyExtraTools();
+ }
+
+ // 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;
+ var dual = IsDualLayout;
+ PlaceEraserButton(dual);
+ if (EraserToolButton is not null)
+ {
+ EraserToolButton.Visibility = eraserTool ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ if (PanToolButton is not null)
+ {
+ PanToolButton.Visibility = extraTools ? Visibility.Visible : Visibility.Collapsed;
+ }
- var extraTools = fingerInk || IsMouseModeEffective;
if (ExtraToolsRow is not null)
{
- ExtraToolsRow.Visibility = extraTools ? Visibility.Visible : Visibility.Collapsed;
+ // 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 (!extraTools && _activeTool is BoardTool.Eraser or BoardTool.Pan)
+ if ((!eraserTool && _activeTool is BoardTool.Eraser) ||
+ (!extraTools && _activeTool is BoardTool.Pan))
{
SetActiveTool(_lastDrawingTool);
}
}
+ // 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);
@@ -2912,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/PreferencesWindow.xaml b/src/SQLBI.Whiteboard/PreferencesWindow.xaml
index 9a6db8c..c01aae4 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"
@@ -21,12 +21,23 @@
+
+
+
+
+
diff --git a/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs b/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs
index a2631f0..32e18a9 100644
--- a/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs
+++ b/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs
@@ -1,7 +1,9 @@
using System.Globalization;
using System.Windows;
+using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
+using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
@@ -12,12 +14,20 @@ namespace SQLBI.Whiteboard;
public partial class PreferencesWindow : Window
{
+ private const double DisclosureColumnWidth = 24;
+
private readonly AppSettings _settings;
private readonly Action _applied;
private readonly IReadOnlyList _monitors;
private string? _selectedCategory;
private bool _suppressChange;
+ // The Eraser's pictures are drawn from the Layout setting, which sits in the
+ // same list and can change under them. Held so that one editor can be
+ // redrawn where rebuilding the list would lose the scroll position and the
+ // rows anyone had opened.
+ private ContentControl? _eraserChoice;
+
public PreferencesWindow(AppSettings settings, Action applied)
{
ArgumentNullException.ThrowIfNull(settings);
@@ -44,12 +54,21 @@ private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
- SearchPlaceholder.Visibility = string.IsNullOrEmpty(SearchBox.Text)
- ? Visibility.Visible
- : Visibility.Collapsed;
+ var empty = string.IsNullOrEmpty(SearchBox.Text);
+ SearchPlaceholder.Visibility = empty ? Visibility.Visible : Visibility.Collapsed;
+
+ // Nothing to clear while the box is empty, and a permanent cross beside
+ // the placeholder would read as a button that does nothing.
+ SearchClear.Visibility = empty ? Visibility.Collapsed : Visibility.Visible;
Rebuild();
}
+ private void SearchClear_Click(object sender, RoutedEventArgs e)
+ {
+ SearchBox.Clear();
+ SearchBox.Focus();
+ }
+
private void Rebuild()
{
var query = SearchBox.Text;
@@ -63,7 +82,7 @@ private void Rebuild()
var visible = SettingsCatalog.Filter(query, _selectedCategory);
RebuildCategories(visibleCategories);
- RebuildSettings(visible);
+ RebuildSettings(visible, query);
}
private void RebuildCategories(IReadOnlyList categories)
@@ -94,9 +113,10 @@ private void CategoryButton_Click(object sender, RoutedEventArgs e)
Rebuild();
}
- private void RebuildSettings(IReadOnlyList settings)
+ private void RebuildSettings(IReadOnlyList settings, string? query)
{
SettingsHost.Children.Clear();
+ _eraserChoice = null;
var empty = settings.Count == 0;
EmptyState.Visibility = empty ? Visibility.Visible : Visibility.Collapsed;
if (empty)
@@ -129,7 +149,7 @@ private void RebuildSettings(IReadOnlyList settings)
lastCategory = setting.Category;
}
- SettingsHost.Children.Add(CreateRow(setting));
+ SettingsHost.Children.Add(CreateRow(setting, query));
}
}
finally
@@ -138,28 +158,109 @@ private void RebuildSettings(IReadOnlyList settings)
}
}
- private Border CreateRow(SettingDescriptor setting)
+ // Search matches are marked where they are rather than only counted, so a
+ // row can say why it is in the list. The term is matched exactly as the
+ // search matched it - one case-insensitive substring - so what is marked is
+ // what was found, and nothing else.
+ private void FillText(TextBlock block, string text, string? query)
{
- var editor = CreateEditor(setting);
- var copy = new StackPanel();
- copy.Children.Add(new TextBlock
+ block.Inlines.Clear();
+ var term = query?.Trim();
+ if (string.IsNullOrEmpty(term))
{
- Style = (Style)FindResource("SettingsTitle"),
- Text = setting.Title,
- });
- copy.Children.Add(new TextBlock
+ block.Text = text;
+ return;
+ }
+
+ var highlight = (Brush)FindResource("SettingsHighlightBrush");
+ var start = 0;
+ while (text.IndexOf(term, start, StringComparison.OrdinalIgnoreCase) is var hit && hit >= 0)
+ {
+ if (hit > start)
+ {
+ block.Inlines.Add(new Run(text[start..hit]));
+ }
+
+ block.Inlines.Add(new Run(text.Substring(hit, term.Length)) { Background = highlight });
+ start = hit + term.Length;
+ }
+
+ block.Inlines.Add(new Run(text[start..]));
+ }
+
+ // 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 CreateCopy(SettingDescriptor setting, string? query)
+ {
+ var copy = new StackPanel();
+ var title = new TextBlock { Style = (Style)FindResource("SettingsTitle") };
+ FillText(title, setting.Title, query);
+ copy.Children.Add(title);
+
+ var summary = new TextBlock { Style = (Style)FindResource("SettingsSummary") };
+ FillText(summary, setting.Summary, query);
+
+ // The chevron goes under the title and in front of the summary, where it
+ // belongs to the words it opens. The right-hand edge of the row cannot
+ // have it: editors live there, and two of them carry chevrons of their
+ // own that a disclosure beside them would be mistaken for.
+ var summaryRow = new Grid();
+ summaryRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(DisclosureColumnWidth) });
+ summaryRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ Grid.SetColumn(summary, 1);
+ summaryRow.Children.Add(summary);
+ copy.Children.Add(summaryRow);
+
+ // A setting whose summary is the whole story has nothing to disclose,
+ // and a chevron on it would promise something that is not there. Its
+ // summary still keeps the indent, so the column of them stays straight.
+ if (setting.Description.Length == 0)
+ {
+ return copy;
+ }
+
+ var detail = new TextBlock
{
Style = (Style)FindResource("SettingsDescription"),
- Text = setting.Description,
- });
+ Margin = new Thickness(DisclosureColumnWidth, 6, 0, 2),
+ Visibility = Visibility.Collapsed,
+ };
+ FillText(detail, setting.Description, query);
+ copy.Children.Add(detail);
+
+ var disclosure = new ToggleButton
+ {
+ Style = (Style)FindResource("SettingsDisclosure"),
+ ToolTip = $"More about {setting.Title}",
+ };
+ AutomationProperties.SetName(disclosure, $"More about {setting.Title}");
+ disclosure.Checked += (_, _) => detail.Visibility = Visibility.Visible;
+ disclosure.Unchecked += (_, _) => detail.Visibility = Visibility.Collapsed;
+ // The match is real but nothing on the row shows it. Marking the chevron
+ // says where it is, which is the whole of what the row can honestly say
+ // without opening itself while someone is still typing.
+ if (SettingsCatalog.MatchesDescriptionOnly(setting, query))
+ {
+ disclosure.Background = (Brush)FindResource("SettingsHighlightBrush");
+ }
+
+ summaryRow.Children.Add(disclosure);
+ return copy;
+ }
+
+ private Border CreateRow(SettingDescriptor setting, string? query)
+ {
+ var editor = CreateEditor(setting);
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 });
+ body.Children.Add(CreateCopy(setting, query));
+
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
{
@@ -170,7 +271,6 @@ private Border CreateRow(SettingDescriptor setting)
Grid.SetColumn(value, 1);
Grid.SetRow(slider, 1);
Grid.SetColumnSpan(slider, 2);
- body.Children.Add(copy);
body.Children.Add(value);
body.Children.Add(slider);
}
@@ -179,23 +279,19 @@ SettingEditorKind.OrderedList or
SettingEditorKind.LaserWeightChoice or
SettingEditorKind.PenButtonChoice or
SettingEditorKind.ToolbarPlacementChoice or
- SettingEditorKind.ToolbarLayoutChoice)
+ SettingEditorKind.ToolbarLayoutChoice or
+ SettingEditorKind.EraserButtonChoice)
{
- body.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
- body.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
Grid.SetRow(editor, 1);
+ Grid.SetColumnSpan(editor, 2);
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);
}
@@ -220,6 +316,7 @@ private FrameworkElement CreateEditor(SettingDescriptor setting) =>
CreateSampleChoice(setting, CreateToolbarPlacementSample),
SettingEditorKind.ToolbarLayoutChoice =>
CreateSampleChoice(setting, CreateToolbarLayoutSample),
+ SettingEditorKind.EraserButtonChoice => CreateEraserButtonChoice(setting),
_ => CreateEnumCombo(setting),
};
@@ -494,13 +591,42 @@ private static Brush Frozen(uint argb)
}
// A miniature of the ink flyout each layout produces.
- private FrameworkElement? CreateToolbarLayoutSample(string id)
+ private FrameworkElement? CreateToolbarLayoutSample(string id) =>
+ Enum.TryParse(id, out var access)
+ ? SampleBoard(SampleToolbarRows(access))
+ : null;
+
+ // The Eraser's two pictures are the same toolbar twice, drawn in whichever
+ // arrangement Layout has chosen, differing only in the Eraser itself - which
+ // is in the accent color because that difference is the entire question.
+ private FrameworkElement CreateEraserButtonSample(string id)
{
- if (!Enum.TryParse(id, out var access))
+ var access = _settings.CalligraphyAccess;
+ var rows = SampleToolbarRows(access);
+ var eraser = SampleEraser(shown: id == SettingsCatalog.EraserButton.On);
+ if (access == CalligraphyAccess.DualPalette)
+ {
+ // Its own row under the palette, which is where it goes there. The
+ // row is added either way, so that turning the Eraser on adds the
+ // Eraser rather than moving everything else.
+ var row = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Margin = new Thickness(0, 2, 0, 2),
+ };
+ row.Children.Add(eraser);
+ rows.Children.Add(row);
+ }
+ else
{
- return null;
+ ((StackPanel)rows.Children[^1]).Children.Add(eraser);
}
+ return SampleBoard(rows);
+ }
+
+ private static StackPanel SampleToolbarRows(CalligraphyAccess access)
+ {
var rows = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Center,
@@ -524,18 +650,34 @@ private static Brush Frozen(uint argb)
break;
}
- return new Border
- {
- Width = 76,
- Height = 46,
- CornerRadius = new CornerRadius(6),
- Background = SampleBoardBrush,
- BorderBrush = SampleEdgeBrush,
- BorderThickness = new Thickness(1),
- Child = rows,
- };
+ return rows;
}
+ // Hidden rather than absent when the Eraser is off, so that the two pictures
+ // are the same picture in everything but the one icon.
+ private System.Windows.Shapes.Path SampleEraser(bool shown) => new()
+ {
+ Width = 10,
+ Height = 10,
+ Stretch = Stretch.Uniform,
+ Fill = SampleAccentBrush,
+ Data = (Geometry)FindResource("EraserGeometry"),
+ Margin = new Thickness(3, 0, 0, 0),
+ VerticalAlignment = VerticalAlignment.Center,
+ Visibility = shown ? Visibility.Visible : Visibility.Hidden,
+ };
+
+ private static Border SampleBoard(UIElement content) => new()
+ {
+ Width = 76,
+ Height = 46,
+ CornerRadius = new CornerRadius(6),
+ Background = SampleBoardBrush,
+ BorderBrush = SampleEdgeBrush,
+ BorderThickness = new Thickness(1),
+ Child = content,
+ };
+
private static StackPanel SampleChipRow(int chips, bool chevron, bool nibs)
{
var row = new StackPanel
@@ -593,6 +735,31 @@ private static StackPanel SampleChipRow(int chips, bool chevron, bool nibs)
// Each option is drawn as the strokes it produces, at the same width and
// opacity the trail itself would use, so the choice is made by looking
// rather than by imagining what a word means.
+ private FrameworkElement CreateEraserButtonChoice(SettingDescriptor setting)
+ {
+ _eraserChoice = new ContentControl
+ {
+ Focusable = false,
+ Content = CreateSampleChoice(setting, CreateEraserButtonSample),
+ };
+ return _eraserChoice;
+ }
+
+ private void RefreshEraserChoice()
+ {
+ if (_eraserChoice is null)
+ {
+ return;
+ }
+
+ var setting = SettingsCatalog.All.FirstOrDefault(
+ item => item.Id == SettingsCatalog.Ids.ShowEraserButton);
+ if (setting is not null)
+ {
+ _eraserChoice.Content = CreateSampleChoice(setting, CreateEraserButtonSample);
+ }
+ }
+
private FrameworkElement CreateLaserWeightChoice(SettingDescriptor setting) =>
CreateSampleChoice(setting, id =>
{
@@ -893,6 +1060,9 @@ private string CurrentEnumId(SettingDescriptor setting) =>
SettingsCatalog.Ids.FingerMode => _settings.FingerMode.ToString(),
SettingsCatalog.Ids.MouseMode => _settings.MouseMode.ToString(),
SettingsCatalog.Ids.PenButton => _settings.PenButtons.Barrel.ToString(),
+ SettingsCatalog.Ids.ShowEraserButton => _settings.ShowEraserButton
+ ? SettingsCatalog.EraserButton.On
+ : SettingsCatalog.EraserButton.Off,
_ => string.Empty,
};
@@ -984,10 +1154,20 @@ when Enum.TryParse(id, out var mouseMode):
when Enum.TryParse(id, out var penButton):
_settings.PenButtons.Barrel = penButton;
break;
+ case SettingsCatalog.Ids.ShowEraserButton:
+ _settings.ShowEraserButton = id == SettingsCatalog.EraserButton.On;
+ break;
default:
return;
}
+ // The Eraser is drawn into whichever toolbar Layout has just chosen, so
+ // its two pictures are stale the moment that choice changes.
+ if (setting.Id == SettingsCatalog.Ids.ToolbarLayout)
+ {
+ RefreshEraserChoice();
+ }
+
NotifyApplied();
}
diff --git a/src/SQLBI.Whiteboard/SettingsCatalog.cs b/src/SQLBI.Whiteboard/SettingsCatalog.cs
index 918936a..d4984b7 100644
--- a/src/SQLBI.Whiteboard/SettingsCatalog.cs
+++ b/src/SQLBI.Whiteboard/SettingsCatalog.cs
@@ -33,6 +33,14 @@ internal enum SettingEditorKind
/// How the ink flyout is arranged, drawn as a miniature of the flyout.
///
ToolbarLayoutChoice,
+
+ ///
+ /// Whether the Eraser is on the toolbar, drawn as the toolbar with and
+ /// without it. It is a boolean, but a switch says nothing about where the
+ /// button would appear, and that is the part worth seeing - so the two
+ /// pictures follow whatever has chosen.
+ ///
+ EraserButtonChoice,
}
internal sealed class SettingChoice
@@ -54,7 +62,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; }
@@ -82,6 +103,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";
@@ -91,6 +113,17 @@ public static class Ids
public const string CheckForUpdates = "updates.check";
}
+ ///
+ /// The two states of . It is stored as a
+ /// boolean and offered as a pair of drawn choices, so it needs choice ids
+ /// where the other booleans need none.
+ ///
+ public static class EraserButton
+ {
+ public const string Off = "Off";
+ public const string On = "On";
+ }
+
public const string Startup = "Startup";
public const string Input = "Input";
public const string Laser = "Laser pointer";
@@ -107,7 +140,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,
},
@@ -116,6 +149,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,
@@ -125,6 +159,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,
@@ -134,6 +169,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,
@@ -149,6 +185,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,
@@ -164,6 +201,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,
@@ -173,6 +211,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,
@@ -187,6 +226,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,
@@ -196,7 +236,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,
@@ -207,7 +247,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,
@@ -218,6 +258,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,
@@ -232,6 +273,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,
@@ -247,6 +289,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,
@@ -264,6 +307,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,
@@ -275,10 +319,26 @@ public static class Ids
],
},
new()
+ {
+ 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.EraserButtonChoice,
+ Choices =
+ [
+ new() { Id = EraserButton.Off, Title = "Off" },
+ new() { Id = EraserButton.On, Title = "On" },
+ ],
+ },
+ new()
{
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,
@@ -320,11 +380,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 marks its chevron, because a hit with nothing
+ /// marked on it 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..99104c1 100644
--- a/src/SQLBI.Whiteboard/Themes/Settings.xaml
+++ b/src/SQLBI.Whiteboard/Themes/Settings.xaml
@@ -9,6 +9,11 @@
+
+
+
+ M0,0 L8,8 M8,0 L0,8
+
+
+
+
+
+
+
+
+
+