diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a4acfb..daf6c6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,11 @@ The spacing you chose around a join hint, as in `kind=inner`, survives formattin that is how the queries people bring to a session are usually written. Highlighting and formatting both read Kusto through Microsoft's own parser, and both run locally. +### Pasted code is recognized without a setting +Plain text now comes last in the snippet format order, so pasted DAX, SQL, or KQL arrives +as code. A bare word or a number still pastes as a note. An order you chose in +Preferences is kept; a language added by an update joins it in front of Plain text. + ## 1.3.0 - 3 September 2026 ### Export a board to PowerPoint diff --git a/README.md b/README.md index abfc511..702570f 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ With the mouse, selection is automatic: click a container to move it, or drag th Imported images, LiveViews, and text objects act as containers. A completed stroke is linked when it touches exactly one container, including crossing its edge; a stroke touching multiple containers remains independent. Moving or resizing a container transforms its linked strokes with it. **View → Bring to front** and **View → Send to back** reorder the selected container and those linked strokes. Deleting a container also deletes all of its linked strokes. Undo/redo treats each complete container operation as one action. -Paste plain text to create a selected text container in display mode. **Help → Preferences** has Snippet format order: paste tries those languages from top to bottom and uses the first that accepts the text. Plain text always accepts, so leaving it first keeps every paste as plain text. Recognized extensions (`.dax`, `.sql`, `.kql`, `.txt`) keep their language; other dropped text files use the same order. Choose **Plain text**, **DAX**, **SQL Server**, or **KQL** from the title-bar chip afterward. Press **F6** to format DAX, SQL, or KQL on the selected container without entering edit. Press **F2** to edit the body; the same list is in the title bar while editing. In F2, **F6** formats in place; **Ctrl+Enter** commits that edit (including the format) and returns to display. **Escape** restores the previous text, language, and dimensions. Text reflows while its edit-mode resize grip changes the width, and the height grows automatically when necessary. Double-click still centers and fits the container. Syntax highlighting applies in both edit and display modes. A language-aware title identifies a defined DAX, SQL, or KQL object when possible. SQL Server mode targets SQL Server 2025 T-SQL, preserves `GO` batch separators, and leaves invalid scripts unchanged. KQL mode reads Kusto through Microsoft's own parser and likewise leaves an invalid query unchanged. In display mode, resizing preserves the aspect ratio and scales the complete text visual without reflowing it. +Paste plain text to create a selected text container in display mode. **Help → Preferences** has Snippet format order: paste tries those languages from top to bottom and uses the first that accepts the text. Plain text always accepts and comes last by default, so DAX, SQL, and KQL are recognized on paste; a language claims a snippet only when it carries an operator, function, or keyword, so a bare word stays a note. Put Plain text first to keep every paste plain. A language added by an update joins in front of Plain text unless Plain text is first. Recognized extensions (`.dax`, `.sql`, `.kql`, `.txt`) keep their language; other dropped text files use the same order. Choose **Plain text**, **DAX**, **SQL Server**, or **KQL** from the title-bar chip afterward. Press **F6** to format DAX, SQL, or KQL on the selected container without entering edit. Press **F2** to edit the body; the same list is in the title bar while editing. In F2, **F6** formats in place; **Ctrl+Enter** commits that edit (including the format) and returns to display. **Escape** restores the previous text, language, and dimensions. Text reflows while its edit-mode resize grip changes the width, and the height grows automatically when necessary. Double-click still centers and fits the container. Syntax highlighting applies in both edit and display modes. A language-aware title identifies a defined DAX, SQL, or KQL object when possible. SQL Server mode targets SQL Server 2025 T-SQL, preserves `GO` batch separators, and leaves invalid scripts unchanged. KQL mode reads Kusto through Microsoft's own parser and likewise leaves an invalid query unchanged. In display mode, resizing preserves the aspect ratio and scales the complete text visual without reflowing it. Image files, and `.txt`, `.dax`, `.sql`, and `.kql` files, can be dropped directly from File Explorer. Their initial center is the board position at which they were dropped. DAX, SQL, and KQL files open in the matching language mode. Other dropped text files use Snippet format order. diff --git a/docs/decisions.md b/docs/decisions.md index 65fd95d..819448d 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -654,6 +654,36 @@ board without one still opens in every release since format 5. --- +## 28. Plain text is the last snippet format, and a language must earn a snippet + +**Implemented.** + +Snippet format order decides which language a paste becomes: the first that accepts the +text wins, and plain text accepts everything. With plain text first, the shipped default +since 1.0, every paste was a note until someone found the setting; a language added by an +update (KQL in 1.3.0) joined the end of the list, behind plain text, and so never applied. +The goal is that a new language works on the day it ships, with no visit to Preferences. + +Two things were changed together, and the second is what makes the first safe: + +- **Plain text comes last** in the default order: DAX, SQL, KQL, Plain text. A saved order + equal to a default the application ever shipped was never chosen by anyone, so an + upgrade replaces it (the settings version moved to 16 to do this once). A chosen order + is kept, and a language it does not know joins it in front of plain text wherever plain + text sits, unless plain text is first, which is the one order that means "keep my pastes + plain"; there the new language goes last. The order itself carries the intent, so the + checkbox that was considered, "new languages go before plain text", was not added: it + would say the same thing twice and allow the contradictory state of plain text first with + languages jumping ahead of it. +- **A language claims a snippet only when it carries a signal** of its own. Every parser + accepts a bare name or a number: `Sales` is a DAX table expression and a KQL query. With + plain text last that would have turned a one-word note into code. So DAX needs a + function, operator, keyword, column reference, or variable; T-SQL a keyword or function; + KQL a pipe, query operator, keyword, command, or function. The parser still has the last + word after the signal. + +--- + ## Open questions - arm64 is not built; add it if Surface devices matter for a pen application. diff --git a/site/guide.html b/site/guide.html index 074568d..77d0b2d 100644 --- a/site/guide.html +++ b/site/guide.html @@ -696,7 +696,7 @@

Containers

Text, DAX, SQL, and KQL

Paste prefers an image when the clipboard has one, including a file SnagIt or another capture tool left on the clipboard. Paste plain text to create a selected text container.

SVG counts as an image, whether the source application published it as SVG or the markup was simply copied as text — the output of a DAX SVG measure pastes as a picture, not as a snippet. Copying an SVG container puts the markup and a bitmap on the clipboard together, so an editor receives the source and a slide receives the picture.

-

Help → Preferences sets Snippet format order: paste tries those languages from top to bottom. Plain text always matches, so putting it first keeps paste as plain text. Use the title-bar chip to choose Plain text, DAX, SQL Server, or KQL afterward.

+

Help → Preferences sets Snippet format order: paste tries those languages from top to bottom, and Plain text comes last, so DAX, SQL, and KQL are recognized while a bare word stays a note. Put Plain text first to keep every paste plain. Use the title-bar chip to change the language afterward.

F6 formats DAX, SQL, or KQL on the selected container, without entering edit mode. F2 edits the container, and double-click still centers it and fits it to the canvas; inside an edit F6 formats in place and Ctrl+Enter commits that format with the rest of the edit. SQL Server mode targets SQL Server 2025 T-SQL, keeps GO batch separators, and leaves an invalid script unchanged; KQL reads Kusto through Microsoft's own parser and likewise leaves an invalid query alone. Escape restores the previous text, language, and size.

LiveView

diff --git a/src/SQLBI.Whiteboard.Core/Model/BoardObjects.cs b/src/SQLBI.Whiteboard.Core/Model/BoardObjects.cs index de054c8..bbd3437 100644 --- a/src/SQLBI.Whiteboard.Core/Model/BoardObjects.cs +++ b/src/SQLBI.Whiteboard.Core/Model/BoardObjects.cs @@ -256,8 +256,43 @@ public static string Normalize(string? languageId) => _ => Plain, }; - public static IReadOnlyList All { get; } = [Plain, Dax, SqlServer, Kql]; + /// + /// The default snippet format order, which is also the order a language + /// missing from a saved order joins it in. Plain text accepts everything, + /// so it comes last: a paste is code if any language says it is, and text + /// otherwise, with no setting to change. + /// + public static IReadOnlyList All { get; } = [Dax, SqlServer, Kql, Plain]; + + /// + /// The default orders earlier releases wrote into settings, with plain text + /// first. A saved order equal to one of these was never chosen by anyone, so + /// an upgrade may replace it with . + /// + public static IReadOnlyList> LegacyDefaultOrders { get; } = + [ + [Plain, Dax, SqlServer], + [Plain, Dax, SqlServer, Kql], + ]; + + public static bool IsLegacyDefaultOrder(IEnumerable? languageIds) + { + if (languageIds is null) + { + return false; + } + var order = languageIds.Select(Normalize).ToArray(); + return LegacyDefaultOrders.Any(legacy => legacy.SequenceEqual(order, StringComparer.Ordinal)); + } + + /// + /// Drops unknown names and duplicates, and adds the languages the saved + /// order does not know. Those go in front of plain text wherever it sits, + /// because an order that puts plain text after some languages means "try + /// the languages first"; only an order that starts with plain text means + /// "keep my pastes plain", and there they go last. + /// public static IReadOnlyList NormalizeOrder(IEnumerable? languageIds) { var ordered = new List(); @@ -273,12 +308,15 @@ public static IReadOnlyList NormalizeOrder(IEnumerable? language } } - foreach (var languageId in All) + var missing = All.Where(languageId => !ordered.Contains(languageId, StringComparer.Ordinal)).ToArray(); + var plainIndex = ordered.IndexOf(Plain); + if (plainIndex > 0) { - if (!ordered.Contains(languageId, StringComparer.Ordinal)) - { - ordered.Add(languageId); - } + ordered.InsertRange(plainIndex, missing); + } + else + { + ordered.AddRange(missing); } return ordered; diff --git a/src/SQLBI.Whiteboard.Core/Settings/AppSettings.cs b/src/SQLBI.Whiteboard.Core/Settings/AppSettings.cs index 5ea97fc..5907d5e 100644 --- a/src/SQLBI.Whiteboard.Core/Settings/AppSettings.cs +++ b/src/SQLBI.Whiteboard.Core/Settings/AppSettings.cs @@ -172,7 +172,14 @@ public sealed class AppSettings public static class AppSettingsSerializer { - public const int CurrentVersion = 15; + public const int CurrentVersion = 16; + + /// + /// The version that moved plain text to the end of the default snippet + /// format order. A file older than this whose order is still a shipped + /// default was never customized, and takes the new default. + /// + private const int VersionWithPlainTextLast = 16; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -278,6 +285,12 @@ private static AppSettings Normalize(AppSettings? settings) settings.Laser = LaserSettings.Normalize(settings.Laser); settings.PenButtons = PenButtonSettings.Normalize(settings.PenButtons); settings.Export = ExportSettings.Normalize(settings.Export); + if (settings.Version < VersionWithPlainTextLast && + TextLanguageIds.IsLegacyDefaultOrder(settings.SnippetFormatOrder)) + { + settings.SnippetFormatOrder = [.. TextLanguageIds.All]; + } + settings.SnippetFormatOrder = [.. TextLanguageIds.NormalizeOrder(settings.SnippetFormatOrder)]; settings.LatestKnownVersion = NormalizeVersionId(settings.LatestKnownVersion); settings.LastDismissedVersion = NormalizeVersionId(settings.LastDismissedVersion); diff --git a/src/SQLBI.Whiteboard.Dax/DaxLanguageEngine.cs b/src/SQLBI.Whiteboard.Dax/DaxLanguageEngine.cs index 050752a..3d1449a 100644 --- a/src/SQLBI.Whiteboard.Dax/DaxLanguageEngine.cs +++ b/src/SQLBI.Whiteboard.Dax/DaxLanguageEngine.cs @@ -51,6 +51,33 @@ public static bool TryFormat( } } + /// + /// Whether the text has something only DAX would have: a function, a + /// keyword, an operator, a column reference, or a variable. A bare name or + /// a number parses as DAX too, and pasted as a note it must stay a note. + /// + public static bool LooksLike(string source) + { + if (string.IsNullOrWhiteSpace(source)) + { + return false; + } + + try + { + return Classify(source).Any(span => span.Classification is + DaxTextClassification.Keyword or + DaxTextClassification.Function or + DaxTextClassification.Operator or + DaxTextClassification.ColumnReference or + DaxTextClassification.Variable); + } + catch (Exception) + { + return false; + } + } + public static IReadOnlyList Classify(string source) => DaxClassifier.Classify(source) .Select(span => new DaxClassifiedSpan( diff --git a/src/SQLBI.Whiteboard.Kql/KqlLanguageEngine.cs b/src/SQLBI.Whiteboard.Kql/KqlLanguageEngine.cs index cb50587..f27bca4 100644 --- a/src/SQLBI.Whiteboard.Kql/KqlLanguageEngine.cs +++ b/src/SQLBI.Whiteboard.Kql/KqlLanguageEngine.cs @@ -99,6 +99,37 @@ public static KqlTextAnalysis Analyze(string source) } } + /// + /// Whether the text has something only KQL would have: a pipe, a query + /// operator, a keyword, a command, or a function. A bare table name is a + /// valid query to the parser, and pasted as a note it must stay a note. + /// + public static bool LooksLike(string source) + { + if (string.IsNullOrWhiteSpace(source)) + { + return false; + } + + if (source.Contains('|')) + { + return true; + } + + try + { + return Classify(source).Any(span => span.Classification is + KqlTextClassification.Keyword or + KqlTextClassification.QueryOperator or + KqlTextClassification.Command or + KqlTextClassification.Function); + } + catch (Exception) + { + return false; + } + } + public static IReadOnlyList Classify(string source) => Analyze(source).Spans; diff --git a/src/SQLBI.Whiteboard.SqlServer/SqlServerLanguageEngine.cs b/src/SQLBI.Whiteboard.SqlServer/SqlServerLanguageEngine.cs index 3a94538..bb8318f 100644 --- a/src/SQLBI.Whiteboard.SqlServer/SqlServerLanguageEngine.cs +++ b/src/SQLBI.Whiteboard.SqlServer/SqlServerLanguageEngine.cs @@ -107,6 +107,30 @@ public static SqlServerTextAnalysis Analyze(string source) } } + /// + /// Whether the text has something only T-SQL would have: a keyword or a + /// function. The parser rejects most prose on its own; this keeps the + /// acceptance rule the same shape as the other languages'. + /// + public static bool LooksLike(string source) + { + if (string.IsNullOrWhiteSpace(source)) + { + return false; + } + + try + { + return Classify(source).Any(span => span.Classification is + SqlServerTextClassification.Keyword or + SqlServerTextClassification.Function); + } + catch (Exception) + { + return false; + } + } + public static IReadOnlyList Classify(string source) => Analyze(source).Spans; diff --git a/src/SQLBI.Whiteboard/SettingsCatalog.cs b/src/SQLBI.Whiteboard/SettingsCatalog.cs index 616964c..ed5c9cb 100644 --- a/src/SQLBI.Whiteboard/SettingsCatalog.cs +++ b/src/SQLBI.Whiteboard/SettingsCatalog.cs @@ -227,7 +227,7 @@ public static class EraserButton 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, .kql, .txt) keep their language.", + Description = "Paste tries formats from top to bottom and uses the first that accepts the text. Plain text always accepts and comes last, so code is recognized and a note stays a note; put it first to keep every paste plain. Recognized file extensions (.dax, .sql, .kql, .txt) keep their language.", Keywords = ["snippet", "language", "dax", "sql", "kql", "paste", "format", "text", "order"], Editor = SettingEditorKind.OrderedList, diff --git a/src/SQLBI.Whiteboard/TextLanguages.cs b/src/SQLBI.Whiteboard/TextLanguages.cs index 4294bca..3768ca4 100644 --- a/src/SQLBI.Whiteboard/TextLanguages.cs +++ b/src/SQLBI.Whiteboard/TextLanguages.cs @@ -135,11 +135,13 @@ public bool TryFormat(string source, out string formatted) => DaxLanguageEngine.DefaultMaximumLineLength, out formatted); + // The parser alone is too welcoming: a bare name or a number parses in + // more than one language. The engine says whether there is a real signal. public bool TryAccept(string source) { try { - return TryFormat(source, out _); + return DaxLanguageEngine.LooksLike(source) && TryFormat(source, out _); } catch (Exception) { @@ -235,11 +237,13 @@ public TextLanguageAnalysis Analyze(string source, string fallbackTitle) public bool TryFormat(string source, out string formatted) => SqlServerLanguageEngine.TryFormat(source, out formatted); + // The parser alone is too welcoming: a bare name or a number parses in + // more than one language. The engine says whether there is a real signal. public bool TryAccept(string source) { try { - return TryFormat(source, out _); + return SqlServerLanguageEngine.LooksLike(source) && TryFormat(source, out _); } catch (Exception) { @@ -342,11 +346,13 @@ public TextLanguageAnalysis Analyze(string source, string fallbackTitle) public bool TryFormat(string source, out string formatted) => KqlLanguageEngine.TryFormat(source, out formatted); + // The parser alone is too welcoming: a bare name or a number parses in + // more than one language. The engine says whether there is a real signal. public bool TryAccept(string source) { try { - return TryFormat(source, out _); + return KqlLanguageEngine.LooksLike(source) && TryFormat(source, out _); } catch (Exception) { diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs index 7f01a7e..31a978e 100644 --- a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs +++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs @@ -950,24 +950,53 @@ defaultSettings.StartupMonitorName is null && eraserButtonRoundTrip.ShowEraserButton, "Settings JSON should round-trip the always-show-the-Eraser choice."); Assert( - defaultSettings.SnippetFormatOrder is ["plain", "dax", "sqlserver", "kql"], - "Missing settings should keep Plain text first so paste stays plain text."); + defaultSettings.SnippetFormatOrder is ["dax", "sqlserver", "kql", "plain"], + "A new setup tries every language before plain text, so pasted code is code without a setting."); Assert( TextLanguageIds.NormalizeOrder(["sqlserver", "plain", "dax", "plain", "python"]) is - ["sqlserver", "plain", "dax", "kql"], - "Snippet format order should drop unknowns, keep first-seen order, and fill missing languages."); + ["sqlserver", "kql", "plain", "dax"], + "Snippet format order should drop unknowns, keep first-seen order, and put a missing language in front of plain text."); var snippetOrderRoundTrip = AppSettingsSerializer.Parse( AppSettingsSerializer.Format(new AppSettings { SnippetFormatOrder = ["dax", "sqlserver", "plain"], })); Assert( - snippetOrderRoundTrip.SnippetFormatOrder is ["dax", "sqlserver", "plain", "kql"], - "A language added after a board was saved should join the order last, not displace it."); + snippetOrderRoundTrip.SnippetFormatOrder is ["dax", "sqlserver", "kql", "plain"], + "A language added by an upgrade joins in front of plain text when plain text is not first."); +Assert( + TextLanguageIds.NormalizeOrder(["plain", "dax"]) is ["plain", "dax", "sqlserver", "kql"], + "An order that starts with plain text keeps pastes plain: added languages go last."); Assert( AppSettingsSerializer.Parse("{ }").SnippetFormatOrder is - ["plain", "dax", "sqlserver", "kql"], + ["dax", "sqlserver", "kql", "plain"], "Partial settings should fill the default snippet format order."); +Assert( + AppSettingsSerializer.Parse("""{ "version": 15, "snippetFormatOrder": ["plain", "dax", "sqlserver"] }""").SnippetFormatOrder is + ["dax", "sqlserver", "kql", "plain"] && + AppSettingsSerializer.Parse("""{ "version": 15, "snippetFormatOrder": ["plain", "dax", "sqlserver", "kql"] }""").SnippetFormatOrder is + ["dax", "sqlserver", "kql", "plain"], + "An older file still holding a shipped default was never customized and takes the new default."); +Assert( + AppSettingsSerializer.Parse("""{ "version": 15, "snippetFormatOrder": ["sqlserver", "plain", "dax"] }""").SnippetFormatOrder is + ["sqlserver", "kql", "plain", "dax"], + "An older file with a chosen order keeps it, with the new language in front of plain text."); +Assert( + AppSettingsSerializer.Parse("""{ "version": 16, "snippetFormatOrder": ["plain", "dax", "sqlserver", "kql"] }""").SnippetFormatOrder is + ["plain", "dax", "sqlserver", "kql"], + "A current file that puts plain text first chose to, and is left alone."); +Assert( + !DaxLanguageEngine.LooksLike("Sales") && !DaxLanguageEngine.LooksLike("42") && !DaxLanguageEngine.LooksLike("Why does December spike?") && + DaxLanguageEngine.LooksLike("Sales Amount := SUM ( Sales[Amount] )") && DaxLanguageEngine.LooksLike("[Amount] * 2"), + "DAX claims a snippet only when it has a function, operator, keyword, or column reference."); +Assert( + !SqlServerLanguageEngine.LooksLike("Sales") && !SqlServerLanguageEngine.LooksLike("42") && + SqlServerLanguageEngine.LooksLike("SELECT 1") && SqlServerLanguageEngine.LooksLike("SELECT COUNT(*) FROM dbo.Sales"), + "SQL claims a snippet only when it has a keyword or function."); +Assert( + !KqlLanguageEngine.LooksLike("Sales") && !KqlLanguageEngine.LooksLike("42") && !KqlLanguageEngine.LooksLike("Open questions") && + KqlLanguageEngine.LooksLike("Sales | count") && KqlLanguageEngine.LooksLike("print 1"), + "KQL claims a snippet only when it has a pipe, operator, keyword, command, or function."); Assert( defaultSettings.PenButtons.Barrel == PenButtonAction.Laser, "Missing settings should assign Laser to the pen barrel button.");