Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ Plain text now comes last in the snippet format order, so pasted DAX, SQL, or KQ
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.

### A snippet's width is its line width
Drag a text container's right edge, or hold **Shift** on its corner handle, to change its
width in columns; the handle shows the count, and a plain drag of the corner still scales.
**F6** wraps DAX to that width. A new container is 65 columns wide, where the DAX
formatter wraps.

## 1.3.0 - 3 September 2026

### Export a board to PowerPoint
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,8 @@ Use **Copy settings** after finding a useful combination so the exact values can
| F2 | Edit the selected text container, or rename the selected frame |
| View > Frame | Add a frame the size of the screen: a slide drawn on the board, selected by its edge or its tab, which Export takes as it is |
| Language chip | Choose Plain text, DAX, or SQL Server on a selected text container |
| F6 | Format DAX, SQL, or KQL on the selected text container. In F2, formats in place |
| F6 | Format DAX, SQL, or KQL on the selected text container; DAX wraps to the container's columns. In F2, formats in place |
| Drag right edge, or Shift + drag handle | Change a text container's width in columns and reflow it; the handle shows the count. A plain drag of the corner scales it |
| Ctrl+Enter | Commit the F2 edit, including an F6 format done in that session, and return to display mode |
| Escape | Cancel the active text edit, close the command strip, or leave full screen or canvas only |
| Ctrl+S / Ctrl+O | Save / open a board |
Expand Down
31 changes: 31 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,37 @@ Two things were changed together, and the second is what makes the first safe:

---

## 29. A text container's width in columns is the line width of its snippet

**Implemented.**

The DAX formatter wraps to a maximum line length; the SQL and KQL formatters break by
structure and have no width at all. A "line width" setting shared by the three languages
was therefore not added: it would be honoured by one of them. What every snippet already
has is a width, and a text container wraps its lines at that width on screen, so the
container's width in columns is the line width the reader sees. Two things were changed
to make it the line width the formatter uses too:

- **F6 formats DAX to the columns the container shows.** Columns are invariant under the
display-mode scaling, so this works whether or not the container has been enlarged for
the room. A new container is 65 columns wide, which is also the formatter's default, so
a pasted and formatted snippet has no visual wraps; before, the default width showed
about 58 columns and formatted DAX wrapped on screen at once.
- **Shift while dragging the handle changes the width in columns** and reflows the text,
keeping the font size; the handle shows the count while dragging. A plain drag keeps
scaling the container like a picture. Replacing scaling with reflow was considered and
rejected: making a snippet bigger for the room is the resize a presenter needs, it is
what every other container does with the same gesture, and reflowing on a drag would
change line breaks while someone is only making space. Shift already means "constrain"
for strokes, so it reads as the narrower version of the gesture. The right edge of a text
container is the same width handle without the modifier: the cursor says so on hover,
which is what makes it discoverable, and the corner stays the scale handle.

Structural switches for SQL and KQL, one column per line or one pipe per line, are the
levers those languages have, and can come as yes-or-no settings when someone asks.

---

## Open questions

- arm64 is not built; add it if Surface devices matter for a pen application.
Expand Down
3 changes: 2 additions & 1 deletion site/guide.html
Original file line number Diff line number Diff line change
Expand Up @@ -697,7 +697,8 @@ <h3>Text, DAX, SQL, and KQL</h3>
<p>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.</p>
<p>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.</p>
<p><span class="ui">Help → Preferences</span> 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.</p>
<p><kbd>F6</kbd> formats DAX, SQL, or KQL on the selected container, without entering edit mode. <kbd>F2</kbd> edits the container, and double-click still centers it and fits it to the canvas; inside an edit <kbd>F6</kbd> formats in place and <kbd>Ctrl</kbd><span class="plus">+</span><kbd>Enter</kbd> commits that format with the rest of the edit. SQL Server mode targets SQL Server 2025 T-SQL, keeps <code>GO</code> batch separators, and leaves an invalid script unchanged; KQL reads Kusto through Microsoft's own parser and likewise leaves an invalid query alone. <kbd>Escape</kbd> restores the previous text, language, and size.</p>
<p>A text container's width is its line width. Drag its right edge to change the width in columns and reflow the text; the handle shows the count. <kbd>Shift</kbd> on the corner handle does the same, and a plain drag of the corner scales the container like a picture. A new container is 65 columns wide, which is where the DAX formatter wraps.</p>
<p><kbd>F6</kbd> formats DAX, SQL, or KQL on the selected container, without entering edit mode, and wraps DAX to the container's columns. <kbd>F2</kbd> edits the container, and double-click still centers it and fits it to the canvas; inside an edit <kbd>F6</kbd> formats in place and <kbd>Ctrl</kbd><span class="plus">+</span><kbd>Enter</kbd> commits that format with the rest of the edit. SQL Server mode targets SQL Server 2025 T-SQL, keeps <code>GO</code> batch separators, and leaves an invalid script unchanged; KQL reads Kusto through Microsoft's own parser and likewise leaves an invalid query alone. <kbd>Escape</kbd> restores the previous text, language, and size.</p>

<h3 id="liveview">LiveView</h3>
<p><span class="ui">View → LiveView</span> captures a window or a display. The container behaves like an image: move, resize, frame, delete, undo.</p>
Expand Down
3 changes: 2 additions & 1 deletion site/shortcuts.html
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ <h2><span class="secico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none"
<div class="keyrow"><span><kbd>Ctrl</kbd><span class="plus">+</span><kbd>E</kbd></span><span class="act">Export the board to PowerPoint or PDF. Commits an active text edit first.</span></div>
<div class="keyrow"><span><kbd>Delete</kbd></span><span class="act">Delete the selected container and its linked strokes.</span></div>
<div class="keyrow"><span><kbd>F2</kbd></span><span class="act">Edit the selected text container, or rename the selected frame.</span></div>
<div class="keyrow"><span><kbd>F6</kbd></span><span class="act">Format DAX, SQL Server, or KQL on the selected text container. In <kbd>F2</kbd>, formats in place; <kbd>Ctrl</kbd><span class="plus">+</span><kbd>Enter</kbd> then commits.</span></div>
<div class="keyrow"><span><kbd>F6</kbd></span><span class="act">Format DAX, SQL Server, or KQL on the selected text container; DAX wraps to the container's columns. In <kbd>F2</kbd>, formats in place; <kbd>Ctrl</kbd><span class="plus">+</span><kbd>Enter</kbd> then commits.</span></div>
<div class="keyrow"><span><kbd>Shift</kbd><span class="plus">+</span><kbd class="gesture">Drag handle</kbd></span><span class="act">Change a text container's width in columns and reflow it; the handle shows the count. Dragging the container's right edge does the same. A plain drag of the corner scales it.</span></div>
<div class="keyrow"><span><kbd>Ctrl</kbd><span class="plus">+</span><kbd>Enter</kbd></span><span class="act">Commit the F2 edit, including an F6 format done in that session, and return to display mode.</span></div>
<div class="keyrow"><span><kbd>Escape</kbd></span><span class="act">Cancel the F2 edit (including an uncommitted F6), close the command strip, or leave full screen or canvas only.</span></div>
<div class="keyrow"><span><kbd>Alt</kbd><span class="plus">+</span><kbd>L</kbd></span><span class="act">Laser pointer.</span></div>
Expand Down
23 changes: 22 additions & 1 deletion src/SQLBI.Whiteboard/BoardSurface.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ internal sealed class BoardSurface : FrameworkElement
/// </summary>
public bool DrawFrames { get; set; } = true;

/// <summary>
/// A word or two beside the resize handle while a gesture needs one, such
/// as the column count of a text container being reflowed.
/// </summary>
public string? HandleLabel { get; set; }

/// <summary>
/// The stroke the pen is drawing right now, before it is committed. Pen ink
/// is collected here rather than by the InkCanvas, so the wet stroke is
Expand Down Expand Up @@ -311,7 +317,7 @@ private bool TryGetImage(
}
}

private static void DrawSelection(
private void DrawSelection(
DrawingContext drawingContext,
RectD bounds,
Camera2D camera,
Expand All @@ -334,6 +340,21 @@ private static void DrawSelection(
7,
7);
}

if (includeHandle && HandleLabel is { Length: > 0 } label)
{
var text = new FormattedText(
label,
System.Globalization.CultureInfo.CurrentUICulture,
FlowDirection.LeftToRight,
FrameTypeface,
12,
FrameTitleBrush,
VisualTreeHelper.GetDpi(this).PixelsPerDip);
var badge = new Rect(bottomRight.X + 12, bottomRight.Y - (text.Height / 2) - 3, text.Width + 12, text.Height + 6);
drawingContext.DrawRoundedRectangle(SelectionPen.Brush, null, badge, 4, 4);
drawingContext.DrawText(text, new Point(badge.Left + 6, badge.Top + 3));
}
}

private static Color ToColor(uint argb) => Color.FromArgb(
Expand Down
98 changes: 92 additions & 6 deletions src/SQLBI.Whiteboard/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ private enum PointerAction
private InkStrokeObject[] _containerGestureLinkedCurrent = [];
private PointD _containerGestureStartWorld;
private bool _containerGestureIsResize;
private bool _containerGestureReflow;
private TextBoardObject? _textEditBefore;
private InkStrokeObject[] _textEditLinkedBefore = [];
private RectD _textEditBounds;
Expand Down Expand Up @@ -2007,9 +2008,22 @@ private void BeginContainerGesture(PointD screenPoint)
{
selected = existing;
_containerGestureIsResize = true;

// Shift on the handle of a text container changes its width in
// columns and reflows the text, keeping the size; a plain drag
// scales it like a picture, as it does every container.
_containerGestureReflow = existing is TextBoardObject &&
Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
}
}

if (!_containerGestureIsResize && FindTextContainerAtRightEdge(screenPoint) is { } edged)
{
selected = edged;
_containerGestureIsResize = true;
_containerGestureReflow = true;
}

_selectedObjectId = selected?.Id;
SceneSurface.SelectedObjectId = _selectedObjectId;
if (selected is null)
Expand Down Expand Up @@ -2037,6 +2051,28 @@ private void UpdateContainerGesture(PointD worldPoint)
}

var bounds = _containerGestureBefore.Bounds;
if (_containerGestureReflow && _containerGestureBefore is TextBoardObject reflowed)
{
double pixelsPerDip = VisualTreeHelper.GetDpi(SceneSurface).PixelsPerDip;
double width = Math.Max(
TextContainerVisual.MinimumWidth * reflowed.VisualScale,
worldPoint.X - bounds.Left);
double height = TextContainerVisual.MeasureDesiredHeight(
reflowed.Text,
width,
reflowed.VisualScale,
pixelsPerDip,
reflowed.LanguageId);
bounds = new RectD(bounds.Left, bounds.Top, width, height);
SceneSurface.HandleLabel = TextContainerVisual.ColumnsFor(width, reflowed.VisualScale, reflowed.LanguageId, pixelsPerDip) + " columns";
_containerGestureCurrent = reflowed with { Bounds = bounds };
_containerGestureLinkedCurrent = _containerGestureLinkedBefore
.Select(stroke => stroke.TransformWithContainer(_containerGestureBefore.Bounds, bounds))
.ToArray();
_document.ReplaceObjects([_containerGestureCurrent, .. _containerGestureLinkedCurrent]);
return;
}

if (_containerGestureIsResize)
{
var minimumWorldSize = 32 / _camera.Zoom;
Expand Down Expand Up @@ -2094,6 +2130,12 @@ private void ResetContainerGesture()
_containerGestureLinkedBefore = [];
_containerGestureLinkedCurrent = [];
_containerGestureIsResize = false;
_containerGestureReflow = false;
if (SceneSurface.HandleLabel is not null)
{
SceneSurface.HandleLabel = null;
SceneSurface.InvalidateVisual();
}
}

private static BoardObject WithBounds(BoardObject item, RectD bounds) => item switch
Expand Down Expand Up @@ -2441,8 +2483,13 @@ private void FormatTextEdit()
}

ITextLanguageService language = TextLanguageRegistry.Resolve(_textEditLanguageId);
int editColumns = TextContainerVisual.ColumnsFor(
_textEditBounds.Width,
_textEditBefore.VisualScale,
_textEditLanguageId,
VisualTreeHelper.GetDpi(SceneSurface).PixelsPerDip);
if (!language.CanFormat ||
!language.TryFormat(TextEditor.Text, out string formatted) ||
!language.TryFormat(TextEditor.Text, editColumns, out string formatted) ||
string.Equals(TextEditor.Text, formatted, StringComparison.Ordinal))
{
return;
Expand All @@ -2469,9 +2516,12 @@ private void FormatSelectedText()
return;
}

// Formatted to the columns the container shows, so the lines fit it:
// the container's width is the line width of the snippet.
ITextLanguageService language = TextLanguageRegistry.Resolve(textObject.LanguageId);
int columns = TextContainerVisual.ColumnsFor(textObject, VisualTreeHelper.GetDpi(SceneSurface).PixelsPerDip);
if (!language.CanFormat ||
!language.TryFormat(textObject.Text, out string formatted) ||
!language.TryFormat(textObject.Text, columns, out string formatted) ||
string.Equals(textObject.Text, formatted, StringComparison.Ordinal))
{
return;
Expand Down Expand Up @@ -3348,7 +3398,8 @@ private void UpdateSelectHover(PointD screen)
return;
}

var container = _document.HitTestTopContainer(_camera.ScreenToWorld(screen), _camera.Zoom);
var container = _document.HitTestTopContainer(_camera.ScreenToWorld(screen), _camera.Zoom)
?? FindTextContainerAtRightEdge(screen);
var hoveredId = container?.Id;
if (SceneSurface.HoveredObjectId == hoveredId)
{
Expand All @@ -3366,11 +3417,45 @@ private Cursor SelectCursorAt(PointD screen)
return Cursors.SizeNWSE;
}

if (FindTextContainerAtRightEdge(screen) is not null)
{
return Cursors.SizeWE;
}

return _document.HitTestTopContainer(_camera.ScreenToWorld(screen), _camera.Zoom) is null
? Cursors.Arrow
: Cursors.SizeAll;
}

/// <summary>
/// The right edge of a text container is its width handle: dragging it
/// changes the columns and reflows, as Shift on the corner does, but with
/// a cursor that says so on hover. The corner itself stays the scale handle.
/// </summary>
private const double EdgeHandleTolerance = 8;

private TextBoardObject? FindTextContainerAtRightEdge(PointD screen)
{
if (IsOverResizeHandle(screen))
{
return null;
}

foreach (var text in _document.Objects.OfType<TextBoardObject>().OrderByDescending(item => item.ZIndex))
{
var topRight = _camera.WorldToScreen(new PointD(text.Bounds.Right, text.Bounds.Top));
var bottom = _camera.WorldToScreen(new PointD(text.Bounds.Right, text.Bounds.Bottom)).Y;
if (Math.Abs(screen.X - topRight.X) <= EdgeHandleTolerance &&
screen.Y >= topRight.Y - EdgeHandleTolerance &&
screen.Y <= bottom - 16)
{
return text;
}
}

return null;
}

private bool IsOverResizeHandle(PointD screen)
{
if (_selectedObjectId is not Guid existingId ||
Expand Down Expand Up @@ -4064,15 +4149,16 @@ private void AddText(
}

double visibleWidth = _camera.VisibleWorldBounds.Width;
double pixelsPerDip = VisualTreeHelper.GetDpi(SceneSurface).PixelsPerDip;
double width = Math.Min(
TextContainerVisual.DefaultWidth,
TextContainerVisual.DefaultWidth(pixelsPerDip),
Math.Max(320, visibleWidth * 0.7));
string resolvedLanguageId = TextLanguageIds.Normalize(languageId);
double height = TextContainerVisual.MeasureDesiredHeight(
text,
width,
1,
VisualTreeHelper.GetDpi(SceneSurface).PixelsPerDip,
pixelsPerDip,
resolvedLanguageId);
PointD center = worldCenter ?? _camera.Center;
var textObject = new TextBoardObject(
Expand Down Expand Up @@ -4942,7 +5028,7 @@ private void ApplyImport(ImportDocument imported, PointD originTopLeft, bool rec
}

var width = Math.Min(
TextContainerVisual.DefaultWidth,
TextContainerVisual.DefaultWidth(dpi),
Math.Max(320, _camera.VisibleWorldBounds.Width * 0.7));
var height = TextContainerVisual.MeasureDesiredHeight(text, width, 1, dpi);
sizes.Add((width, height, item.StartNewRow));
Expand Down
Loading