diff --git a/CHANGELOG.md b/CHANGELOG.md
index b4b2388..835e067 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,18 @@ broke. The heading is parsed by `scripts/release-notes.ps1`, so keep its shape;
under it is ordinary Markdown, and the renderer handles paragraphs, lists, links, `code`
and **bold**.
+## 1.7.8 - 31 October 2026
+
+### Go to a LiveView's source window
+A LiveView of a window has a **Go to source window** button that brings that window forward,
+on whichever virtual desktop it is. Ctrl+Alt+Shift+W does the same from Whiteboard, and from
+any other application returns to Whiteboard and resumes the LiveView.
+**Preferences → Live View → Source window shortcut** offers Ctrl+Alt+. or Ctrl+Alt+J instead.
+
+### Ink across overlapping containers
+A stroke that touches more than one container now moves with the frontmost one. It used to
+stay on the canvas, linked to none of them.
+
## 1.7.7 - 29 October 2026
### Clear strokes
diff --git a/Directory.Build.props b/Directory.Build.props
index 1f0747a..86777fc 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.7.7
+ 1.7.8latestenableenable
diff --git a/README.md b/README.md
index 2355bcb..7f135f4 100644
--- a/README.md
+++ b/README.md
@@ -234,10 +234,10 @@ Use **Copy settings** after finding a useful combination so the exact values can
| Ctrl + tap or drag | Add what is tapped, or what the area takes, to the selection instead of replacing it |
| Shift + drag a shape | Constrain the new shape to a square |
| Ctrl at release (connector) | Bind the endpoint to the nearest point anywhere on the object's border, rather than to one of its eight binding points. **Anchors → Auto** on the property bar moves a bound end to the side facing the other end instead |
-| Help > Preferences | Searchable settings: mode, startup monitor, full screen at startup or when idle, no-pen warning, finger drawing, mouse drawing, pen button, snippet format order, area selection, background grid, laser trail, toolbar position and layout, Insert and Lasso on the toolbar, the Insert palette, after inserting an object, 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 |
+| Help > Preferences | Searchable settings: mode, startup monitor, full screen at startup or when idle, no-pen warning, finger drawing, mouse drawing, pen button, snippet format order, area selection, background grid, laser trail, toolbar position and layout, Insert and Lasso on the toolbar, the Insert palette, after inserting an object, always show the Eraser, the LiveView source window shortcut, 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 / Bring forward / Send backward / Send to back | Reorder the selection (and its linked strokes) as one block. Letters B, W, K, and S while the View strip is open, and the same four behind the property bar's … |
| 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 |
+| View > LiveView | Capture, freeze, disconnect, or reconnect a window or display. **Go to source window** is available when the captured window can be identified uniquely; **Ctrl+Alt+Shift+W**, or the keys chosen in Preferences, switches between that window and Whiteboard across virtual desktops and resumes the feed on return |
| F11 | Fill the current monitor and hide title and tabs. Escape leaves it when a text container is not being edited. **Preferences → Startup → Go full screen when idle** does the same after 20 seconds without input in Whiteboard, even while another app has focus |
| Ctrl+F11 | Hide title and tabs but keep this window’s size and place |
@@ -245,7 +245,7 @@ With the mouse, selection is automatic: click a container to move it, or drag th
**Help → Preferences → Mouse drawing** changes what the left button means, and it defaults to on when Windows reports neither a pen tablet nor a touchscreen. With it on the left button uses the selected tool, the tool stays selected rather than being handed back, and Eraser and Pan join the toolbar as they do for finger drawing. Everything above then moves to Ctrl: Ctrl and the left button select, move, and resize a container and return to the previous drawing tool, and Ctrl with a double-click centers and fits one. Two plain clicks with an ink tool are two strokes, which is why framing moves out of the way. Shift still constrains a stroke to horizontal or vertical, and Alt+L is still the laser. A mouse reports no pressure, so ink is drawn at an even width; Calligraphy still varies, because its width comes from speed. The pen path is untouched, so Mouse drawing can be left on beside a pen.
-Imported images, LiveViews, text objects, shapes, and text labels 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. A shape is picked up by its outline rather than by its interior, so ink drawn inside one stays reachable, and a label is picked up anywhere on its rotated rectangle. Moving, resizing, or turning a container transforms its linked strokes with it. **View → Bring to front**, **Bring forward**, **Send backward**, and **Send to back** reorder the selection and those linked strokes as one block. Deleting a container also deletes all of its linked strokes. Undo/redo treats each complete container operation as one action.
+Imported images, LiveViews, text objects, shapes, and text labels act as containers. A completed stroke links to the frontmost container it touches, including crossing its edge; a stroke touching no container remains independent. A shape is picked up by its outline rather than by its interior, so ink drawn inside one stays reachable, and a label is picked up anywhere on its rotated rectangle. Moving, resizing, or turning a container transforms its linked strokes with it. **View → Bring to front**, **Bring forward**, **Send backward**, and **Send to back** reorder the selection and those linked strokes as one block. 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 and comes last by default, so DAX, SQL, KQL, and Markdown 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**, **Markdown**, **DAX**, **SQL Server**, or **KQL** from the title-bar chip afterward, or one of **Python**, **C**, **C++**, **Java**, **C#**, **JavaScript**, **TypeScript**, **Visual Basic .NET**, **R**, **Rust**, and **PHP**, which are highlighted but not formatted and are never chosen by paste; **F6** on one of them opens that language's issue on GitHub. 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.
diff --git a/docs/export.md b/docs/export.md
index fa75269..63ce8e5 100644
--- a/docs/export.md
+++ b/docs/export.md
@@ -19,7 +19,7 @@ the machine).
The board is one unbounded plane. A `.wboard` is a flat list of objects in world
coordinates — ink strokes, images, text containers, and LiveViews — each with a bounding
rectangle and a z-index that is also its creation order, plus the assets the images refer
-to. A stroke that touches exactly one container is linked to it and moves with it. Nothing
+to. A newly drawn stroke links to the frontmost container it touches and moves with it. Nothing
in the file says where a page begins or ends. The first two exports have to decide it, and
the third does not need it.
diff --git a/docs/liveview-navigation.md b/docs/liveview-navigation.md
new file mode 100644
index 0000000..5f53e5d
--- /dev/null
+++ b/docs/liveview-navigation.md
@@ -0,0 +1,98 @@
+# LiveView window navigation
+
+Implemented locally: the third on-container button activates the source window.
+One system-wide shortcut, Ctrl+Alt+Shift+W by default, does the same from Whiteboard
+and returns to the existing Whiteboard window from anywhere else, resuming the feed.
+
+## Window identity and desktops
+
+The original Windows capture picker is used for both windows and displays. After
+selection, Whiteboard compares the capture title and pixel dimensions with open
+windows. Candidate dimensions come from temporary capture items, so window borders
+and DPI do not require approximate size comparisons. No additional capture session
+is started. Windows on other virtual desktops are included in enumeration.
+
+A single matching candidate enables navigation. Duplicate matches, changed titles,
+resizing, or errors leave navigation disabled without interrupting capture. Display
+capture descriptions are checked first to avoid linking a display to a window with
+an identical title and size. This is a heuristic: a unique description is not proof
+of identity, and a window changing while matching can make the result inconclusive.
+
+Once matched, the HWND and process ID are retained for the capture session; later
+title changes do not trigger another search. The association is cleared on disconnect
+or target closure. Reopening a board still requires Reconnect, which runs the same
+picker and matching process again.
+
+Navigation restores a minimized window and calls `SetForegroundWindow`. Windows
+selects the virtual desktop containing that existing window. Neither source nor
+Whiteboard is moved to another desktop. This follows the window's current desktop;
+if the user moves it during the visit, the return follows its new location.
+
+## Shortcut
+
+`RegisterHotKey` delivers the shortcut to Whiteboard whichever window is in front,
+so one key serves both directions. When Whiteboard's main window is active, it goes
+to the selected LiveView's source, then the last one visited, then the only LiveView
+with a source window. When another application is active, it returns to Whiteboard
+and resumes the LiveView last visited. It does nothing while a Whiteboard dialog or
+a label or shape text edit is active.
+
+A registered hotkey takes the keys from every other application, including shortcuts
+an application handles in its own window, which `RegisterHotKey` cannot detect. The
+shortcut is therefore registered only while at least one LiveView has an available
+source window, or a visit is under way, and released otherwise. A visit keeps it
+registered when the source closes, so the return still works. A registration conflict
+is silent when a LiveView connects, reported by **Go to source window** before leaving
+the board, and reported when the shortcut is changed in Preferences.
+
+**Preferences → Live View → Source window shortcut** offers a fixed list:
+Ctrl+Alt+Shift+W, Ctrl+Alt+., and Ctrl+Alt+J. A list, rather than recording any
+combination, avoids having to reject Whiteboard's own shortcuts, Windows-key
+combinations reserved by the operating system, and Ctrl+Alt combinations that type a
+character with AltGr on the current keyboard layout. Ctrl+Alt+Shift+W is the default
+because three modifiers keep it out of AltGr typing and out of most applications'
+shortcut tables. Ctrl+Alt+J, for example, is used by Visual Studio.
+
+Jumping resumes a frozen source. During the visit, that feed is exempt from
+**Pause when Whiteboard loses focus**, so edits can be captured before returning
+to another desktop. Other feeds retain the preference. Activating Whiteboard ends
+the exemption. Frame delivery still depends on the source application and Windows
+Graphics Capture; activation does not guarantee that a new frame has already arrived.
+
+## Validation
+
+The default smoke suite checks matching with supplied capture descriptions, including
+unique matches, duplicates, size mismatches, title changes, and inaccessible candidates.
+It also checks native window identity, closed-window rejection, and hotkey registration
+conflicts and cleanup. These checks do not require Windows Graphics Capture.
+
+Real capture tests require an interactive Windows desktop and are explicitly enabled:
+
+```powershell
+dotnet run --project tests/SQLBI.Whiteboard.SmokeTests/SQLBI.Whiteboard.SmokeTests.csproj -c Release -- --liveview-navigation-capture
+```
+
+They create offscreen windows without activating them, verify capture and matching,
+and check that failure leaves the selected capture item usable. GitHub's hosted runner
+rejected capture-item creation with E_INVALIDARG, so this integration check is not part
+of the default CI suite. Neither suite changes virtual desktops.
+
+Interactive checks still required on Windows 11:
+
+1. Capture a window with the Windows picker, jump with the shortcut, edit its contents,
+ and return with the same shortcut. Repeat with the button for the first jump.
+2. Repeat with the source and Whiteboard on different virtual desktops. Confirm both
+ jumps follow the appropriate desktop and the edit appears in the feed.
+3. Repeat with a minimized source, a frozen LiveView, and background capture pausing on.
+4. Close the source during the visit. The shortcut must still return to Whiteboard.
+5. Choose a display through the system picker. Freeze and Resume must still work;
+ the jump button must stay disabled. Repeat with two windows whose titles and dimensions match.
+6. Change the shortcut in Preferences with a LiveView connected. The new keys must work
+ at once, the old ones must reach other applications again, and the button tooltip
+ must name the new keys.
+7. Disconnect every LiveView, then close Whiteboard, and confirm another instance or
+ application can register the shortcut each time.
+
+References: [foreground activation and virtual desktops](https://devblogs.microsoft.com/oldnewthing/20171002-00/?p=97116),
+[capture by HWND](https://learn.microsoft.com/en-us/windows/win32/api/windows.graphics.capture.interop/nf-windows-graphics-capture-interop-igraphicscaptureiteminterop-createforwindow),
+[global hotkey registration](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerhotkey).
diff --git a/docs/microsoft-whiteboard-import.md b/docs/microsoft-whiteboard-import.md
index 3b30953..e3399ec 100644
--- a/docs/microsoft-whiteboard-import.md
+++ b/docs/microsoft-whiteboard-import.md
@@ -152,8 +152,8 @@ Windows client offers, from `paleYellowGradient` (#FEE15A) to `grayGradient` (#C
Coordinates stay in canvas pixels, one to one with board units, so a 4-pixel Microsoft pen
is as wide as the 4-unit pen here. A new board puts the export's top-left corner at the
-origin. A stroke that touches exactly one imported container travels with it, which is the
-rule for a stroke drawn by hand.
+origin. A stroke that touches exactly one imported container travels with it. This import rule differs from the
+rule for newly drawn strokes, which link to the frontmost container they touch.
**Shape kinds** are read from the outline, because the shape's name on the page is
written in the language of whoever made the board. Curves with straight sides are a tube
diff --git a/site/guide.html b/site/guide.html
index 4b08f76..3741319 100644
--- a/site/guide.html
+++ b/site/guide.html
@@ -195,11 +195,11 @@
Pin a live demo without leaving the board
Keep ink on the slide you are discussing
-
A finished stroke that touches only one container (an image, a text block, or a LiveView) travels with that container when you move or resize it. A stroke that crosses two containers stays on the canvas. Delete the container and its linked ink goes with it. Undo treats the whole operation as one step.
+
A finished stroke travels with the frontmost container it touches when you move or resize that container. Containers underneath do not take the stroke. Delete the container and its linked ink goes with it. Undo treats the whole operation as one step.
-
1 sits wholly inside the chart container, so it travels with it. The dashed outline is where the container was.
-
2 crosses into the notes container, so it stays on the canvas.
+
2 touches both containers and links to the notes container, which is in front.
@@ -969,7 +969,7 @@
Shapes, connectors, and text
Select comes back once one object is drawn, and Escape returns to Select at any time. Preferences → Toolbar keeps it for the next one instead, adds Insert to the floating palette, or pins the Insert row into a second palette you can drag anywhere. Shapes, connectors, and labels are saved in the board, undone with everything else, and exported to a deck or a PDF as objects you can select and change — in PowerPoint the straight arrows are still attached to their shapes.
LiveView
-
View → LiveView captures a window or a display. The container behaves like an image: move, resize, frame, delete, undo.
+
View → LiveView captures a window or a display. When the captured window can be identified uniquely, Go to source window opens it, and Ctrl+Alt+Shift+W switches between it and Whiteboard, resuming the feed each time you return. Each jump follows the window to its virtual desktop, and Preferences → Live View offers two other keys. The container behaves like an image: move, resize, frame, delete, undo.
Turn on Preferences → Live View → Pause when Whiteboard loses focus to stop capture while you use another application and keep the last frame visible. Returning to Whiteboard resumes the views that were playing, leaving manually paused views paused. Whiteboard's own dialogs do not interrupt capture.
Ctrl+Alt+Shift+WGo to the source window of the selected LiveView, or of the last one visited. From any other application or virtual desktop, return to Whiteboard and resume that LiveView. Preferences → Live View offers Ctrl+Alt+. or Ctrl+Alt+J instead.
+
+
Mouse
diff --git a/src/SQLBI.Whiteboard.Core/Model/BoardDocument.cs b/src/SQLBI.Whiteboard.Core/Model/BoardDocument.cs
index 0ae9a2d..3a0ea08 100644
--- a/src/SQLBI.Whiteboard.Core/Model/BoardDocument.cs
+++ b/src/SQLBI.Whiteboard.Core/Model/BoardDocument.cs
@@ -291,26 +291,10 @@ public IReadOnlyList GrowSelection(IEnumerable seed, ExtendSelection
public IEnumerable Frames => _objects.OfType();
- public BoardObject? FindSingleTouchedContainer(InkStrokeObject stroke)
- {
- BoardObject? match = null;
- foreach (var item in _objects.Where(item => item is IBoardContainer))
- {
- if (!stroke.Touches(item.Bounds))
- {
- continue;
- }
-
- if (match is not null)
- {
- return null;
- }
-
- match = item;
- }
-
- return match;
- }
+ public BoardObject? FindTopTouchedContainer(InkStrokeObject stroke) =>
+ _objects.Where(item => item is IBoardContainer)
+ .OrderByDescending(item => item.ZIndex)
+ .FirstOrDefault(item => stroke.Touches(item.Bounds));
///
/// The connectors with an end bound to this object. A gesture that moves it
diff --git a/src/SQLBI.Whiteboard.Core/Settings/AppSettings.cs b/src/SQLBI.Whiteboard.Core/Settings/AppSettings.cs
index b173d3c..0b3604d 100644
--- a/src/SQLBI.Whiteboard.Core/Settings/AppSettings.cs
+++ b/src/SQLBI.Whiteboard.Core/Settings/AppSettings.cs
@@ -160,6 +160,18 @@ public enum AfterInsert
ReturnToSelect = 1,
}
+///
+/// The system-wide key that switches between Whiteboard and the source window
+/// of a LiveView. A fixed list, because a free choice would also have to keep
+/// out Whiteboard's own shortcuts and characters typed with AltGr.
+///
+public enum LiveViewShortcut
+{
+ ControlAltShiftW = 0,
+ ControlAltPeriod = 1,
+ ControlAltJ = 2,
+}
+
public sealed class AppSettings
{
public int Version { get; set; } = AppSettingsSerializer.CurrentVersion;
@@ -298,6 +310,8 @@ public sealed class AppSettings
public bool PauseLiveViewsWhenUnfocused { get; set; }
+ public LiveViewShortcut LiveViewShortcut { get; set; } = LiveViewShortcut.ControlAltShiftW;
+
///
/// Whether to say at startup that Windows reports nothing to draw with. The
/// tablet list this reads is a list of digitizers rather than an answer
@@ -462,6 +476,11 @@ private static AppSettings Normalize(AppSettings? settings)
settings.AfterInsert = AfterInsert.ReturnToSelect;
}
+ if (!Enum.IsDefined(settings.LiveViewShortcut))
+ {
+ settings.LiveViewShortcut = LiveViewShortcut.ControlAltShiftW;
+ }
+
settings.InsertPaletteX = NormalizeFraction(settings.InsertPaletteX);
settings.InsertPaletteY = NormalizeFraction(settings.InsertPaletteY);
diff --git a/src/SQLBI.Whiteboard/LiveView/LiveViewCaptureSession.cs b/src/SQLBI.Whiteboard/LiveView/LiveViewCaptureSession.cs
index 2cbf4c2..de47925 100644
--- a/src/SQLBI.Whiteboard/LiveView/LiveViewCaptureSession.cs
+++ b/src/SQLBI.Whiteboard/LiveView/LiveViewCaptureSession.cs
@@ -478,6 +478,7 @@ private void HandleTargetClosed(GraphicsCaptureItem sender)
private void ApplyMinimumUpdateInterval(GraphicsCaptureSession? session)
{
if (session is not null &&
+ OperatingSystem.IsWindowsVersionAtLeast(10, 0, 26100) &&
ApiInformation.IsPropertyPresent(
"Windows.Graphics.Capture.GraphicsCaptureSession",
nameof(GraphicsCaptureSession.MinUpdateInterval)))
diff --git a/src/SQLBI.Whiteboard/LiveView/LiveViewHotkey.cs b/src/SQLBI.Whiteboard/LiveView/LiveViewHotkey.cs
new file mode 100644
index 0000000..eac68d1
--- /dev/null
+++ b/src/SQLBI.Whiteboard/LiveView/LiveViewHotkey.cs
@@ -0,0 +1,85 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using System.Windows.Interop;
+using SQLBI.Whiteboard.Core.Settings;
+
+namespace SQLBI.Whiteboard.LiveView;
+
+internal sealed class LiveViewHotkey : IDisposable
+{
+ private const int HotkeyId = 0x5742;
+ private const uint NoRepeat = 0x4000;
+ private const uint Alt = 0x0001;
+ private const uint Control = 0x0002;
+ private const uint Shift = 0x0004;
+ private readonly HwndSource _source;
+ private readonly Action _pressed;
+ private (uint Modifiers, uint Key)? _registered;
+
+ public LiveViewHotkey(nint window, Action pressed)
+ {
+ _source = HwndSource.FromHwnd(window) ?? throw new InvalidOperationException("Whiteboard has no window handle.");
+ _pressed = pressed;
+ _source.AddHook(WindowMessage);
+ }
+
+ public bool IsRegistered => _registered is not null;
+
+ public static string Label(LiveViewShortcut shortcut) => shortcut switch
+ {
+ LiveViewShortcut.ControlAltPeriod => "Ctrl+Alt+.",
+ LiveViewShortcut.ControlAltJ => "Ctrl+Alt+J",
+ _ => "Ctrl+Alt+Shift+W",
+ };
+
+ // Windows-key combinations belong to the OS.
+ private static (uint Modifiers, uint Key) Keys(LiveViewShortcut shortcut) => shortcut switch
+ {
+ LiveViewShortcut.ControlAltPeriod => (Control | Alt, 0xBE),
+ LiveViewShortcut.ControlAltJ => (Control | Alt, 0x4A),
+ _ => (Control | Alt | Shift, 0x57),
+ };
+
+ public void Register(LiveViewShortcut shortcut)
+ {
+ var (modifiers, key) = Keys(shortcut);
+ Register(modifiers, key, Label(shortcut));
+ }
+
+ public void Register(uint modifiers, uint key, string label)
+ {
+ if (_registered == (modifiers, key)) return;
+ Unregister();
+ if (!RegisterHotKey(_source.Handle, HotkeyId, modifiers | NoRepeat, key))
+ throw new Win32Exception(Marshal.GetLastWin32Error(), $"{label} is already in use. Release that shortcut in the other application, or choose another one in Preferences.");
+ _registered = (modifiers, key);
+ }
+
+ public void Unregister()
+ {
+ if (_registered is null) return;
+ UnregisterHotKey(_source.Handle, HotkeyId);
+ _registered = null;
+ }
+
+ private nint WindowMessage(nint hwnd, int message, nint wParam, nint lParam, ref bool handled)
+ {
+ if (message == 0x0312 && wParam == HotkeyId)
+ {
+ handled = true;
+ _pressed();
+ }
+ return 0;
+ }
+
+ public void Dispose()
+ {
+ Unregister();
+ _source.RemoveHook(WindowMessage);
+ }
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern bool RegisterHotKey(nint window, int id, uint modifiers, uint key);
+ [DllImport("user32.dll")]
+ private static extern bool UnregisterHotKey(nint window, int id);
+}
diff --git a/src/SQLBI.Whiteboard/LiveView/LiveViewPresenter.cs b/src/SQLBI.Whiteboard/LiveView/LiveViewPresenter.cs
index 2575275..28a1a97 100644
--- a/src/SQLBI.Whiteboard/LiveView/LiveViewPresenter.cs
+++ b/src/SQLBI.Whiteboard/LiveView/LiveViewPresenter.cs
@@ -64,6 +64,8 @@ public LiveViewPresenter(Guid objectId, Dispatcher dispatcher)
public bool HasTarget => _capture.HasTarget;
+ public LiveViewWindowTarget? WindowTarget { get; private set; }
+
public bool IsFrozen => _capture.IsFrozen;
public int DesiredFrameRate
@@ -78,10 +80,12 @@ public bool CaptureCursor
set => _capture.CaptureCursor = value;
}
- public void SetTarget(GraphicsCaptureItem item)
+ public void SetTarget(GraphicsCaptureItem item, LiveViewWindowTarget? windowTarget = null)
{
_failureReported = false;
+ WindowTarget = null;
_capture.SetTarget(item);
+ WindowTarget = windowTarget;
}
public void Freeze() => _capture.Freeze();
@@ -90,7 +94,11 @@ public void SetTarget(GraphicsCaptureItem item)
public void SetSuspended(bool suspended) => _capture.SetSuspended(suspended);
- public void ClearTarget() => _capture.ClearTarget();
+ public void ClearTarget()
+ {
+ WindowTarget = null;
+ _capture.ClearTarget();
+ }
public byte[]? CaptureSnapshotPng()
{
@@ -205,7 +213,11 @@ private void Capture_ContentSizeChanged(SizeInt32 size)
private void Capture_TargetClosed()
{
- _ = _dispatcher.BeginInvoke(() => TargetClosed?.Invoke(ObjectId));
+ _ = _dispatcher.BeginInvoke(() =>
+ {
+ WindowTarget = null;
+ TargetClosed?.Invoke(ObjectId);
+ });
}
private void Capture_CaptureFailed(Exception exception)
diff --git a/src/SQLBI.Whiteboard/LiveView/LiveViewWindowMatcher.cs b/src/SQLBI.Whiteboard/LiveView/LiveViewWindowMatcher.cs
new file mode 100644
index 0000000..b9b00a5
--- /dev/null
+++ b/src/SQLBI.Whiteboard/LiveView/LiveViewWindowMatcher.cs
@@ -0,0 +1,35 @@
+using System.Diagnostics;
+
+namespace SQLBI.Whiteboard.LiveView;
+
+internal readonly record struct LiveViewCaptureDescription(string Title, int Width, int Height);
+
+internal static class LiveViewWindowMatcher
+{
+ public static LiveViewWindowTarget? Match(
+ LiveViewCaptureDescription selected,
+ IEnumerable candidates,
+ Func describe)
+ {
+ if (string.IsNullOrWhiteSpace(selected.Title) || selected.Width <= 0 || selected.Height <= 0)
+ return null;
+ try
+ {
+ LiveViewWindowTarget? match = null;
+ foreach (var candidate in candidates)
+ {
+ if (!string.Equals(candidate.Title, selected.Title, StringComparison.Ordinal)) continue;
+ if (describe(candidate) != selected) continue;
+ if (match is not null) return null;
+ match = candidate;
+ }
+ return match;
+ }
+ catch (Exception exception)
+ {
+ // A candidate disappearing or becoming inaccessible makes identification uncertain.
+ Debug.WriteLine($"[LiveView] Could not compare capture descriptions: {exception}");
+ return null;
+ }
+ }
+}
diff --git a/src/SQLBI.Whiteboard/LiveView/LiveViewWindowTarget.cs b/src/SQLBI.Whiteboard/LiveView/LiveViewWindowTarget.cs
new file mode 100644
index 0000000..2b2a3d5
--- /dev/null
+++ b/src/SQLBI.Whiteboard/LiveView/LiveViewWindowTarget.cs
@@ -0,0 +1,131 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Text;
+using Windows.Graphics.Capture;
+using WinRT;
+
+namespace SQLBI.Whiteboard.LiveView;
+
+internal sealed record LiveViewWindowTarget(nint Handle, uint ProcessId, string Title)
+{
+ public bool IsAvailable => IsWindow(Handle) &&
+ GetWindowThreadProcessId(Handle, out var processId) != 0 && processId == ProcessId;
+
+ public bool Activate() => IsAvailable && ActivateWindow(Handle);
+
+ public static bool ActivateWindow(nint handle)
+ {
+ if (!IsWindow(handle)) return false;
+ if (IsIconic(handle)) ShowWindow(handle, 9);
+ // Activating the existing HWND lets Windows select its virtual desktop.
+ return SetForegroundWindow(handle);
+ }
+
+ public static IReadOnlyList Enumerate()
+ {
+ List windows = [];
+ EnumWindows((handle, _) =>
+ {
+ GetWindowThreadProcessId(handle, out uint processId);
+ if (processId == Environment.ProcessId || !IsWindowVisible(handle)) return true;
+ var title = new StringBuilder(1024);
+ GetWindowText(handle, title, title.Capacity);
+ if (title.Length > 0) windows.Add(new(handle, processId, title.ToString()));
+ return true;
+ }, 0);
+ return windows.OrderBy(window => window.Title, StringComparer.CurrentCultureIgnoreCase).ToArray();
+ }
+
+ public static LiveViewWindowTarget? TryMatch(
+ GraphicsCaptureItem selected, IEnumerable? candidates = null)
+ {
+ try
+ {
+ // A display and a window can have the same label and dimensions.
+ bool displayMatch = false;
+ bool monitorReadFailed = false;
+ bool enumerated = EnumDisplayMonitors(0, 0, (monitor, _, _, _) =>
+ {
+ try
+ {
+ var interop = GraphicsCaptureItem.As();
+ var iid = new Guid("79C3F95B-31F7-4EC2-A464-632EF5D30760");
+ var pointer = interop.CreateForMonitor(monitor, ref iid);
+ GraphicsCaptureItem item;
+ try { item = GraphicsCaptureItem.FromAbi(pointer); }
+ finally { Marshal.Release(pointer); }
+ try { displayMatch |= SameDescription(selected, item); }
+ finally { WinRtThreading.Release(item); }
+ return !displayMatch;
+ }
+ catch (Exception exception)
+ {
+ Debug.WriteLine($"[LiveView] Could not identify display: {exception}");
+ monitorReadFailed = true;
+ return false;
+ }
+ }, 0);
+ return displayMatch || monitorReadFailed || !enumerated
+ ? null : FindUniqueMatch(selected, candidates ?? Enumerate());
+ }
+ catch (Exception exception)
+ {
+ // Matching is optional; failure must not interrupt the selected capture.
+ Debug.WriteLine($"[LiveView] Could not identify the selected window: {exception}");
+ return null;
+ }
+ }
+
+ internal static LiveViewWindowTarget? FindUniqueMatch(
+ GraphicsCaptureItem selected, IEnumerable candidates)
+ {
+ var description = new LiveViewCaptureDescription(selected.DisplayName, selected.Size.Width, selected.Size.Height);
+ var match = LiveViewWindowMatcher.Match(description, candidates, candidate =>
+ {
+ // Query capture dimensions instead of window bounds, which include different borders.
+ var item = candidate.CreateCaptureItem();
+ try
+ {
+ return new LiveViewCaptureDescription(item.DisplayName, item.Size.Width, item.Size.Height);
+ }
+ finally { WinRtThreading.Release(item); }
+ });
+ return match is { IsAvailable: true } ? match : null;
+ }
+
+ private static bool SameDescription(GraphicsCaptureItem left, GraphicsCaptureItem right) =>
+ string.Equals(left.DisplayName, right.DisplayName, StringComparison.Ordinal) &&
+ left.Size.Width == right.Size.Width && left.Size.Height == right.Size.Height;
+
+ public GraphicsCaptureItem CreateCaptureItem()
+ {
+ if (!IsAvailable) throw new InvalidOperationException("The selected window has closed. Choose another window.");
+ var interop = GraphicsCaptureItem.As();
+ var iid = new Guid("79C3F95B-31F7-4EC2-A464-632EF5D30760");
+ nint pointer = interop.CreateForWindow(Handle, ref iid);
+ try { return GraphicsCaptureItem.FromAbi(pointer); }
+ finally { Marshal.Release(pointer); }
+ }
+
+ [ComImport, Guid("3628E81B-3CAC-4C60-B7F4-23CE0E0C3356")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ private interface IGraphicsCaptureItemInterop
+ {
+ nint CreateForWindow(nint window, ref Guid iid);
+ nint CreateForMonitor(nint monitor, ref Guid iid);
+ }
+
+ private delegate bool EnumWindowsCallback(nint window, nint parameter);
+ private delegate bool EnumMonitorCallback(nint monitor, nint deviceContext, nint rectangle, nint parameter);
+ [DllImport("user32.dll")]
+ private static extern bool EnumDisplayMonitors(nint deviceContext, nint clip, EnumMonitorCallback callback, nint parameter);
+ [DllImport("user32.dll")] private static extern bool EnumWindows(EnumWindowsCallback callback, nint parameter);
+ [DllImport("user32.dll")] private static extern bool IsWindow(nint window);
+ [DllImport("user32.dll")] private static extern bool IsWindowVisible(nint window);
+ [DllImport("user32.dll")] private static extern bool IsIconic(nint window);
+ [DllImport("user32.dll")] private static extern bool ShowWindow(nint window, int command);
+ [DllImport("user32.dll")] private static extern bool SetForegroundWindow(nint window);
+ [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(nint window, out uint processId);
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern int GetWindowText(nint window, StringBuilder text, int count);
+}
diff --git a/src/SQLBI.Whiteboard/MainWindow.xaml b/src/SQLBI.Whiteboard/MainWindow.xaml
index a1e7683..96995d0 100644
--- a/src/SQLBI.Whiteboard/MainWindow.xaml
+++ b/src/SQLBI.Whiteboard/MainWindow.xaml
@@ -84,7 +84,7 @@
ClipToBounds="True"
IsHitTestVisible="True">
+
+
+
diff --git a/src/SQLBI.Whiteboard/MainWindow.xaml.cs b/src/SQLBI.Whiteboard/MainWindow.xaml.cs
index 4f5419a..8393389 100644
--- a/src/SQLBI.Whiteboard/MainWindow.xaml.cs
+++ b/src/SQLBI.Whiteboard/MainWindow.xaml.cs
@@ -31,6 +31,124 @@ namespace SQLBI.Whiteboard;
public partial class MainWindow : Window
{
+ private LiveViewHotkey? _liveViewHotkey;
+ private LiveViewShortcut _liveViewShortcut;
+ private Guid? _returnLiveViewId;
+ private bool _liveViewJumpActive;
+
+ private async void JumpLiveViewButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (GetSelectedLiveView() is { } liveView &&
+ _liveViewPresenters.TryGetValue(liveView.Id, out var presenter) &&
+ presenter.WindowTarget is { } target)
+ {
+ await GoToLiveViewSourceAsync(liveView, target);
+ }
+ }
+
+ private void LiveViewHotkey_Pressed()
+ {
+ if (!_applicationIsActive)
+ {
+ ReturnFromLiveView();
+ }
+ else if (IsActive && _labelEditBefore is null && _shapeEditBefore is null &&
+ FindLiveViewSource() is { } source)
+ {
+ _ = GoToLiveViewSourceAsync(source.LiveView, source.Target);
+ }
+ }
+
+ private (LiveViewBoardObject LiveView, LiveViewWindowTarget Target)? FindLiveViewSource()
+ {
+ List<(LiveViewBoardObject LiveView, LiveViewWindowTarget Target)> sources = [];
+ foreach (var liveView in _document.Objects.OfType())
+ {
+ if (_liveViewPresenters.TryGetValue(liveView.Id, out var presenter) &&
+ presenter.WindowTarget is { IsAvailable: true } target)
+ {
+ sources.Add((liveView, target));
+ }
+ }
+
+ // The selected LiveView, then the one last visited, then the only one with a source window.
+ Guid?[] preferred = [GetSelectedLiveView()?.Id, _returnLiveViewId];
+ foreach (var id in preferred)
+ {
+ int index = sources.FindIndex(source => source.LiveView.Id == id);
+ if (index >= 0) return sources[index];
+ }
+ return sources.Count == 1 ? sources[0] : null;
+ }
+
+ private void RegisterLiveViewHotkey()
+ {
+ _liveViewHotkey ??= new LiveViewHotkey(new WindowInteropHelper(this).Handle, LiveViewHotkey_Pressed);
+ _liveViewHotkey.Register(_settings.LiveViewShortcut);
+ }
+
+ // Held only while there is a source window to go to or a visit to return from,
+ // because a registered hotkey takes the keys from every other application.
+ private void UpdateLiveViewHotkey(bool reportConflict = false)
+ {
+ if (_closeConfirmed) return;
+ bool needed = _liveViewJumpActive ||
+ _liveViewPresenters.Values.Any(presenter => presenter.WindowTarget is { IsAvailable: true });
+ try
+ {
+ if (needed) RegisterLiveViewHotkey();
+ else _liveViewHotkey?.Unregister();
+ }
+ catch (Win32Exception exception)
+ {
+ // Otherwise the jump button reports the conflict when it is used.
+ if (reportConflict) ShowError("Could not register the LiveView shortcut", exception);
+ else Debug.WriteLine($"[LiveView] {exception.Message}");
+ }
+ }
+
+ private async Task GoToLiveViewSourceAsync(LiveViewBoardObject liveView, LiveViewWindowTarget target)
+ {
+ try
+ {
+ CommitTextEdit();
+ RegisterLiveViewHotkey();
+ _returnLiveViewId = liveView.Id;
+ await ResumeLiveViewAsync(liveView);
+ _liveViewJumpActive = true;
+ if (!target.Activate())
+ {
+ _liveViewJumpActive = false;
+ _returnLiveViewId = null;
+ throw new InvalidOperationException("The source window could not be activated. It may have closed; reconnect LiveView to choose it again.");
+ }
+ }
+ catch (Exception exception)
+ {
+ _liveViewJumpActive = false;
+ ShowError("Could not go to LiveView window", exception);
+ }
+ }
+
+ private async void ReturnFromLiveView()
+ {
+ try
+ {
+ if (!LiveViewWindowTarget.ActivateWindow(new WindowInteropHelper(this).Handle)) return;
+ if (_returnLiveViewId is Guid id &&
+ _document.Objects.OfType().FirstOrDefault(item => item.Id == id) is { } liveView &&
+ _liveViewPresenters.TryGetValue(id, out var presenter) && presenter.HasTarget)
+ {
+ SelectOnly(id);
+ await ResumeLiveViewAsync(liveView);
+ UpdateLiveViewSuspension();
+ SceneSurface.InvalidateVisual();
+ }
+ InkSurface.Focus();
+ }
+ catch (Exception exception) { ShowError("Could not return to LiveView", exception); }
+ }
+
private enum BoardTool
{
Pen,
@@ -358,6 +476,7 @@ public MainWindow(string? initialBoardPath)
Application.Current.Activated += Application_Activated;
Application.Current.Deactivated += Application_Deactivated;
_connectorKind = _settings.Connector.Kind;
+ _liveViewShortcut = _settings.LiveViewShortcut;
LoadInkFromSettings();
ApplyLaserSettings();
ApplyToolbarPlacement();
@@ -688,7 +807,7 @@ private void CommitInkPoints(IReadOnlyList points)
points,
_penStyle,
_document.NextZIndex);
- if (_document.FindSingleTouchedContainer(stroke) is { } container)
+ if (_document.FindTopTouchedContainer(stroke) is { } container)
{
stroke = stroke with { ContainerId = container.Id };
}
@@ -7121,6 +7240,11 @@ private void ApplyPreferences()
{
_autoFullScreen.Enabled = _settings.EnableAutoFullScreen;
UpdateLiveViewSuspension();
+ // Report a conflict only for keys just chosen, not on every other preference change.
+ bool shortcutChanged = _liveViewShortcut != _settings.LiveViewShortcut;
+ _liveViewShortcut = _settings.LiveViewShortcut;
+ UpdateLiveViewHotkey(reportConflict: shortcutChanged);
+ UpdateLiveViewActionOverlay();
ApplyToolbarPlacement();
ApplyCalligraphyAccess();
ApplyLaserSettings();
@@ -7642,6 +7766,7 @@ private void DisconnectSelectedLiveView()
liveView with { IsFrozen = true },
presenter);
presenter.ClearTarget();
+ UpdateLiveViewHotkey();
DetachLiveViewSurface(presenter);
_document.ReplaceObject(updated);
UpdateLiveViewMenuItems();
@@ -8029,7 +8154,8 @@ private async Task AddLiveViewAsync()
return;
}
- GraphicsCaptureItem? item = await PickLiveViewTargetAsync();
+ var selection = await PickLiveViewTargetAsync();
+ GraphicsCaptureItem? item = selection?.Item;
if (item is null)
{
return;
@@ -8051,12 +8177,12 @@ private async Task AddLiveViewAsync()
width,
height),
new LiveViewSourceConfiguration(
- LiveViewSourceKind.Unknown,
+ selection!.Window is null ? LiveViewSourceKind.Unknown : LiveViewSourceKind.Window,
DisplayNameOrFallback(item.DisplayName)),
IsFrozen: false);
_history.Execute(new AddObjectCommand(liveView), _document);
- AttachLiveViewPresenter(liveView, item);
+ AttachLiveViewPresenter(liveView, item, selection!.Window);
SelectOnly(liveView.Id);
SceneSurface.InvalidateVisual();
SetActiveTool(BoardTool.Select);
@@ -8070,7 +8196,8 @@ private async Task AddLiveViewAsync()
private async Task ReconnectLiveViewAsync(LiveViewBoardObject liveView)
{
- GraphicsCaptureItem? item = await PickLiveViewTargetAsync();
+ var selection = await PickLiveViewTargetAsync();
+ GraphicsCaptureItem? item = selection?.Item;
if (item is null)
{
return;
@@ -8087,6 +8214,7 @@ private async Task ReconnectLiveViewAsync(LiveViewBoardObject liveView)
Bounds = updatedBounds,
Source = liveView.Source with
{
+ Kind = selection!.Window is null ? LiveViewSourceKind.Unknown : LiveViewSourceKind.Window,
DisplayName = DisplayNameOrFallback(item.DisplayName),
},
IsFrozen = false,
@@ -8095,7 +8223,7 @@ private async Task ReconnectLiveViewAsync(LiveViewBoardObject liveView)
.Select(stroke => stroke.TransformWithContainer(liveView.Bounds, updatedBounds))
.ToArray();
_document.ReplaceObjects([updated, .. linkedStrokes]);
- AttachLiveViewPresenter(updated, item);
+ AttachLiveViewPresenter(updated, item, selection!.Window);
SceneSurface.InvalidateVisual();
UpdateLiveViewMenuItems();
UpdateLiveViewActionOverlay();
@@ -8106,7 +8234,9 @@ private async Task ReconnectLiveViewAsync(LiveViewBoardObject liveView)
}
}
- private async Task PickLiveViewTargetAsync()
+ private sealed record LiveViewSelection(GraphicsCaptureItem Item, LiveViewWindowTarget? Window);
+
+ private async Task PickLiveViewTargetAsync()
{
WinRtThreading.EnsureDispatcherQueue();
GraphicsCapturePicker picker = new();
@@ -8114,7 +8244,13 @@ private async Task ReconnectLiveViewAsync(LiveViewBoardObject liveView)
{
nint windowHandle = new WindowInteropHelper(this).Handle;
WinRT.Interop.InitializeWithWindow.Initialize(picker, windowHandle);
- return await picker.PickSingleItemAsync();
+ var item = await picker.PickSingleItemAsync();
+ return item is null ? null : new LiveViewSelection(item, LiveViewWindowTarget.TryMatch(item));
+ }
+ catch (Exception exception)
+ {
+ ShowError("Could not select LiveView source", exception);
+ return null;
}
finally
{
@@ -8124,7 +8260,7 @@ private async Task ReconnectLiveViewAsync(LiveViewBoardObject liveView)
private void AttachLiveViewPresenter(
LiveViewBoardObject liveView,
- GraphicsCaptureItem item)
+ GraphicsCaptureItem item, LiveViewWindowTarget? windowTarget)
{
if (_liveViewPresenters.TryGetValue(liveView.Id, out LiveViewPresenter? existing))
{
@@ -8132,7 +8268,8 @@ private void AttachLiveViewPresenter(
existing.CaptureCursor = liveView.CaptureCursor;
existing.SetSuspended(ShouldSuspendLiveViews);
AttachLiveViewSurface(existing);
- existing.SetTarget(item);
+ existing.SetTarget(item, windowTarget);
+ UpdateLiveViewHotkey();
return;
}
@@ -8147,15 +8284,18 @@ private void AttachLiveViewPresenter(
_liveViewPresenters.Add(liveView.Id, presenter);
presenter.SetSuspended(ShouldSuspendLiveViews);
AttachLiveViewSurface(presenter);
- presenter.SetTarget(item);
+ presenter.SetTarget(item, windowTarget);
+ UpdateLiveViewHotkey();
}
private bool ShouldSuspendLiveViews => _settings.PauseLiveViewsWhenUnfocused && !_applicationIsActive;
private void Application_Activated(object? sender, EventArgs e)
{
+ _liveViewJumpActive = false;
_applicationIsActive = true;
UpdateLiveViewSuspension();
+ UpdateLiveViewHotkey();
}
private void Application_Deactivated(object? sender, EventArgs e)
@@ -8175,7 +8315,8 @@ private void UpdateLiveViewSuspension()
{
try
{
- presenter.SetSuspended(ShouldSuspendLiveViews);
+ presenter.SetSuspended(ShouldSuspendLiveViews &&
+ !(_liveViewJumpActive && presenter.ObjectId == _returnLiveViewId));
}
catch (Exception exception)
{
@@ -8205,6 +8346,7 @@ private void LiveViewPresenter_TargetClosed(Guid objectId)
presenter);
_document.ReplaceObject(updated);
DetachLiveViewSurface(presenter);
+ UpdateLiveViewHotkey();
UpdateLiveViewMenuItems();
}
catch (Exception exception)
@@ -8301,7 +8443,7 @@ private void UpdateLiveViewActionOverlay()
PointD bottomRight = _camera.WorldToScreen(
new PointD(liveView.Bounds.Right, liveView.Bounds.Bottom));
- const double overlayWidth = 104;
+ const double overlayWidth = 152;
const double overlayHeight = 48;
const double inset = 8;
Canvas.SetLeft(
@@ -8314,6 +8456,10 @@ private void UpdateLiveViewActionOverlay()
bool isRunning = IsLiveViewRunning(liveView.Id);
PauseLiveViewButton.IsEnabled = isRunning;
PlayLiveViewButton.IsEnabled = !isRunning;
+ JumpLiveViewButton.IsEnabled = overlayPresenter.WindowTarget is { IsAvailable: true };
+ JumpLiveViewButton.ToolTip = JumpLiveViewButton.IsEnabled
+ ? $"Go to source window. {LiveViewHotkey.Label(_settings.LiveViewShortcut)} switches between it and Whiteboard."
+ : "The source window could not be identified uniquely. LiveView capture is still available.";
LiveViewActionsBorder.Visibility = Visibility.Visible;
UpdateLanguageChipOverlay();
UpdateZOrderCommands();
@@ -8628,6 +8774,7 @@ private void DisposeLiveViewPresenter(Guid objectId)
presenter.CaptureFailed -= LiveViewPresenter_CaptureFailed;
DetachLiveViewSurface(presenter);
presenter.Dispose();
+ UpdateLiveViewHotkey();
}
private void AttachLiveViewSurface(LiveViewPresenter presenter)
@@ -9998,6 +10145,7 @@ private async void Window_Closing(object? sender, CancelEventArgs e)
return;
}
+ _liveViewHotkey?.Dispose();
_autosaveTimer.Stop();
_mermaidRenderer?.Dispose();
_autoFullScreen.Dispose();
diff --git a/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs b/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs
index 369fd9b..bbdd987 100644
--- a/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs
+++ b/src/SQLBI.Whiteboard/PreferencesWindow.xaml.cs
@@ -1149,6 +1149,7 @@ private string CurrentEnumId(SettingDescriptor setting) =>
SettingsCatalog.Ids.PenButton => _settings.PenButtons.Barrel.ToString(),
SettingsCatalog.Ids.Grid => _settings.Grid.ToString(),
SettingsCatalog.Ids.AfterInsert => _settings.AfterInsert.ToString(),
+ SettingsCatalog.Ids.LiveViewShortcut => _settings.LiveViewShortcut.ToString(),
// A boolean offered as two pictures answers with the id of the
// picture in force, so one read serves every drawn choice.
@@ -1322,6 +1323,10 @@ when Enum.TryParse(id, out var extendSelection):
when Enum.TryParse(id, out var afterInsert):
_settings.AfterInsert = afterInsert;
break;
+ case SettingsCatalog.Ids.LiveViewShortcut
+ when Enum.TryParse(id, out var liveViewShortcut):
+ _settings.LiveViewShortcut = liveViewShortcut;
+ break;
case SettingsCatalog.Ids.Grid
when Enum.TryParse(id, out var grid):
_settings.Grid = grid;
diff --git a/src/SQLBI.Whiteboard/SettingsCatalog.cs b/src/SQLBI.Whiteboard/SettingsCatalog.cs
index 93251c5..d0644b6 100644
--- a/src/SQLBI.Whiteboard/SettingsCatalog.cs
+++ b/src/SQLBI.Whiteboard/SettingsCatalog.cs
@@ -138,6 +138,7 @@ public static class Ids
public const string ImportHorizontalSpacing = "import.horizontalSpacing";
public const string ImportVerticalSpacing = "import.verticalSpacing";
public const string PauseLiveViewsWhenUnfocused = "liveView.pauseWhenUnfocused";
+ public const string LiveViewShortcut = "liveView.shortcut";
public const string LaserHoldSeconds = "laser.holdSeconds";
public const string LaserFadeSeconds = "laser.fadeSeconds";
public const string LaserHoldMode = "laser.holdMode";
@@ -480,6 +481,22 @@ private static bool HasExtendedSelection(AppSettings settings) =>
Editor = SettingEditorKind.BooleanCheckbox,
},
new()
+ {
+ Id = Ids.LiveViewShortcut,
+ Category = LiveView,
+ Title = "Source window shortcut",
+ Summary = "Switch between Whiteboard and a LiveView's window",
+ Description = "In Whiteboard, goes to the source window of the selected LiveView, or of the last one visited. From any other application or virtual desktop, returns to Whiteboard and resumes that LiveView. Another application that uses the same keys does not receive them while a LiveView shows a window.",
+ Keywords = ["liveview", "live view", "shortcut", "hotkey", "keyboard", "keys", "switch", "source", "window", "return", "virtual desktop", "ctrl", "alt"],
+ Editor = SettingEditorKind.EnumChoice,
+ Choices =
+ [
+ new() { Id = nameof(Core.Settings.LiveViewShortcut.ControlAltShiftW), Title = "Ctrl+Alt+Shift+W" },
+ new() { Id = nameof(Core.Settings.LiveViewShortcut.ControlAltPeriod), Title = "Ctrl+Alt+." },
+ new() { Id = nameof(Core.Settings.LiveViewShortcut.ControlAltJ), Title = "Ctrl+Alt+J" },
+ ],
+ },
+ new()
{
Id = Ids.LaserHoldSeconds,
Category = Laser,
diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs
index 7bf1587..3c66700 100644
--- a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs
+++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs
@@ -30,6 +30,17 @@
"The LiveView focus preference must round-trip in both states.");
}
+Assert(new AppSettings().LiveViewShortcut == LiveViewShortcut.ControlAltShiftW &&
+ AppSettingsSerializer.Parse("{\"version\":19}").LiveViewShortcut == LiveViewShortcut.ControlAltShiftW &&
+ AppSettingsSerializer.Parse("{\"liveViewShortcut\":7}").LiveViewShortcut == LiveViewShortcut.ControlAltShiftW,
+ "The LiveView shortcut must default to Ctrl+Alt+Shift+W, including for an unrecognized value.");
+foreach (var shortcut in Enum.GetValues())
+{
+ var saved = AppSettingsSerializer.Format(new AppSettings { LiveViewShortcut = shortcut });
+ Assert(AppSettingsSerializer.Parse(saved).LiveViewShortcut == shortcut,
+ "Every LiveView shortcut preset must round-trip.");
+}
+
var camera = new Camera2D();
camera.Resize(1000, 800);
var anchor = new PointD(720, 310);
@@ -507,7 +518,7 @@ kqlFromFence.Items is
new PenStyle(0xFF000000, 4),
containerDocument.NextZIndex);
Assert(
- containerDocument.FindSingleTouchedContainer(singleContainerStroke)?.Id == firstContainer.Id,
+ containerDocument.FindTopTouchedContainer(singleContainerStroke)?.Id == firstContainer.Id,
"A stroke touching exactly one container should link to it.");
var ambiguousStroke = InkStrokeObject.Create(
[
@@ -517,8 +528,34 @@ kqlFromFence.Items is
new PenStyle(0xFF000000, 4),
containerDocument.NextZIndex);
Assert(
- containerDocument.FindSingleTouchedContainer(ambiguousStroke) is null,
- "A stroke touching multiple containers must remain unlinked.");
+ containerDocument.FindTopTouchedContainer(ambiguousStroke)?.Id == secondContainer.Id,
+ "A stroke touching multiple containers must link to the frontmost one.");
+
+var overlapStroke = InkStrokeObject.Create(
+ [new InkPoint(new PointD(275, 150), 0.5f, 1)],
+ PenStyle.Default,
+ containerDocument.NextZIndex);
+Assert(
+ containerDocument.FindTopTouchedContainer(overlapStroke)?.Id == secondContainer.Id,
+ "Ink in an overlap must link to the top container.");
+var reorderedContainers = new BoardDocument();
+reorderedContainers.AddObject(firstContainer with { ZIndex = secondContainer.ZIndex + 1 });
+reorderedContainers.AddObject(secondContainer);
+Assert(
+ reorderedContainers.FindTopTouchedContainer(overlapStroke)?.Id == firstContainer.Id,
+ "Ink linking must follow depth order rather than insertion order.");
+var outsideStroke = InkStrokeObject.Create(
+ [new InkPoint(new PointD(0, 0), 0.5f, 1)],
+ PenStyle.Default,
+ containerDocument.NextZIndex);
+Assert(containerDocument.FindTopTouchedContainer(outsideStroke) is null,
+ "Ink outside every container must remain independent.");
+var crossingOverlapStroke = InkStrokeObject.Create(
+ [new InkPoint(new PointD(0, 150), 0.5f, 1), new InkPoint(new PointD(480, 150), 0.5f, 2)],
+ PenStyle.Default,
+ containerDocument.NextZIndex);
+Assert(containerDocument.FindTopTouchedContainer(crossingOverlapStroke)?.Id == secondContainer.Id,
+ "Segments crossing containers must link even when neither endpoint is inside.");
var liveViewContainer = new LiveViewBoardObject(
Guid.NewGuid(),
@@ -534,7 +571,7 @@ [new InkPoint(new PointD(520, 120), 0.5f, 1)],
PenStyle.Default,
containerDocument.NextZIndex);
Assert(
- containerDocument.FindSingleTouchedContainer(liveViewStroke)?.Id == liveViewContainer.Id,
+ containerDocument.FindTopTouchedContainer(liveViewStroke)?.Id == liveViewContainer.Id,
"A stroke touching one LiveView should link to it like any other container.");
var textContainer = new TextBoardObject(
Guid.NewGuid(),
@@ -815,7 +852,7 @@ [new InkPoint(new PointD(160, 160), 0.6f, 3)],
var markdownInk = InkStrokeObject.Create([new InkPoint(new PointD(100, 120), 0.6f, 1)],
PenStyle.Default, 1, containerId: markdownObject.Id);
markdownDocument.AddObject(markdownInk);
-Assert(markdownDocument.FindSingleTouchedContainer(markdownInk)?.Id == markdownObject.Id &&
+Assert(markdownDocument.FindTopTouchedContainer(markdownInk)?.Id == markdownObject.Id &&
markdownDocument.HitTestTopContainer(new PointD(100, 120))?.Id == markdownObject.Id,
"Markdown remains an ordinary ink-linkable text container.");
await using var markdownArchive = new MemoryStream();
@@ -2938,7 +2975,7 @@ [new ExportPage("Vector", null, onePixelPng, 1600, 900, vectorElements)],
PenStyle.Default,
shapeBoard.NextZIndex);
Assert(
- shapeBoard.FindSingleTouchedContainer(shapeStroke)?.Id == linkedShape.Id,
+ shapeBoard.FindTopTouchedContainer(shapeStroke)?.Id == linkedShape.Id,
"A stroke that touches only a shape links to it, as it does to any other container.");
// Only a board that needs version 7 asks for it.
@@ -3581,7 +3618,7 @@ turnedByHandle is
PenStyle.Default,
labelBoard.NextZIndex);
Assert(
- labelBoard.FindSingleTouchedContainer(overLabel)?.Id == upright.Id,
+ labelBoard.FindTopTouchedContainer(overLabel)?.Id == upright.Id,
"A stroke drawn across a label alone links to the label.");
labelBoard.AddObject(overLabel with { ContainerId = upright.Id });
Assert(
diff --git a/tests/SQLBI.Whiteboard.SmokeTests/LiveViewNavigationSmokeTests.cs b/tests/SQLBI.Whiteboard.SmokeTests/LiveViewNavigationSmokeTests.cs
new file mode 100644
index 0000000..dce8ce5
--- /dev/null
+++ b/tests/SQLBI.Whiteboard.SmokeTests/LiveViewNavigationSmokeTests.cs
@@ -0,0 +1,141 @@
+using System.ComponentModel;
+using System.Windows.Interop;
+using SQLBI.Whiteboard.Core.Settings;
+using SQLBI.Whiteboard.LiveView;
+
+namespace SQLBI.Whiteboard.SmokeTests;
+
+internal static class LiveViewNavigationSmokeTests
+{
+ public static void Run(bool includeCapture = false)
+ {
+ CheckMatching();
+ Exception? failure = null;
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ using var window = new HwndSource(new HwndSourceParameters("LiveView navigation test")
+ {
+ Width = 200,
+ Height = 100,
+ WindowStyle = 0x10CF0000,
+ ExtendedWindowStyle = 0x08000000,
+ PositionX = -20000,
+ PositionY = -20000,
+ });
+ var target = new LiveViewWindowTarget(window.Handle, (uint)Environment.ProcessId, "LiveView navigation test");
+ Assert(target.IsAvailable, "The selected window must retain its identity.");
+ Assert(!(target with { ProcessId = 0 }).IsAvailable,
+ "A reused handle owned by another process must not be activated.");
+ Assert(!new LiveViewWindowTarget(0, 0, "Closed").Activate(),
+ "A closed target must fail without activating another window.");
+ if (includeCapture)
+ {
+ WinRtThreading.EnsureDispatcherQueue();
+ var item = target.CreateCaptureItem();
+ try
+ {
+ Assert(item.DisplayName == "LiveView navigation test",
+ "The capture item must refer to the selected HWND.");
+ Assert(LiveViewWindowTarget.TryMatch(item, [target]) == target,
+ "A unique title and capture size must enable navigation.");
+ Assert(LiveViewWindowTarget.TryMatch(item, []) is null,
+ "No matching window must leave navigation unavailable.");
+ Assert(LiveViewWindowTarget.TryMatch(item, [target with { Title = "Different title" }]) is null,
+ "Matching dimensions alone must not identify a window.");
+ using var duplicate = new HwndSource(new HwndSourceParameters("LiveView navigation test")
+ {
+ Width = 200, Height = 100, WindowStyle = 0x10CF0000,
+ ExtendedWindowStyle = 0x08000000, PositionX = -20000, PositionY = -20000,
+ });
+ var duplicateTarget = new LiveViewWindowTarget(duplicate.Handle, (uint)Environment.ProcessId, target.Title);
+ Assert(LiveViewWindowTarget.TryMatch(item, [target, duplicateTarget]) is null,
+ "Identical titles and dimensions must remain ambiguous.");
+ using var larger = new HwndSource(new HwndSourceParameters("LiveView navigation test")
+ {
+ Width = 400, Height = 300, WindowStyle = 0x10CF0000,
+ ExtendedWindowStyle = 0x08000000, PositionX = -20000, PositionY = -20000,
+ });
+ var largerTarget = new LiveViewWindowTarget(larger.Handle, (uint)Environment.ProcessId, target.Title);
+ Assert(LiveViewWindowTarget.TryMatch(item, [largerTarget]) is null,
+ "Matching titles alone must not identify a window.");
+ Assert(LiveViewWindowTarget.TryMatch(item, [largerTarget, target]) == target,
+ "Capture size must distinguish windows sharing a title.");
+ duplicate.Dispose();
+ Assert(LiveViewWindowTarget.TryMatch(item, [target, duplicateTarget]) is null,
+ "A candidate closing during matching must not interrupt capture or cause a guess.");
+ Assert(item.Size.Width > 0,
+ "Failed matching must leave the selected capture item usable.");
+ }
+ finally { WinRtThreading.Release(item); }
+ }
+
+ using var otherWindow = new HwndSource(new HwndSourceParameters("LiveView shortcut test"));
+ using var shortcut = new LiveViewHotkey(window.Handle, () => { });
+ using var otherShortcut = new LiveViewHotkey(otherWindow.Handle, () => { });
+ // Use Ctrl+Alt+Shift+F23 and F24 so a running Whiteboard keeps its presets.
+ shortcut.Register(0x7, 0x87, "Ctrl+Alt+Shift+F24");
+ shortcut.Register(0x7, 0x87, "Ctrl+Alt+Shift+F24");
+ Assert(shortcut.IsRegistered, "Registering the same keys twice must keep them.");
+ bool conflict = false;
+ try { otherShortcut.Register(0x7, 0x87, "Ctrl+Alt+Shift+F24"); }
+ catch (Win32Exception) { conflict = true; }
+ Assert(conflict && !otherShortcut.IsRegistered, "A shortcut owned by another window must report a conflict.");
+ shortcut.Register(0x7, 0x86, "Ctrl+Alt+Shift+F23");
+ otherShortcut.Register(0x7, 0x87, "Ctrl+Alt+Shift+F24");
+ Assert(otherShortcut.IsRegistered, "Changing the shortcut must release the previous keys.");
+ shortcut.Unregister();
+ Assert(!shortcut.IsRegistered, "Releasing the shortcut must report it as released.");
+ otherShortcut.Register(0x7, 0x86, "Ctrl+Alt+Shift+F23");
+ shortcut.Dispose();
+ otherShortcut.Dispose();
+ foreach (var preset in Enum.GetValues())
+ {
+ Assert(LiveViewHotkey.Label(preset).StartsWith("Ctrl+Alt+", StringComparison.Ordinal),
+ $"{preset} must name its keys.");
+ }
+ window.Dispose();
+ Assert(!target.IsAvailable, "Closing the selected window must invalidate the jump target.");
+ }
+ catch (Exception exception) { failure = exception; }
+ });
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ if (!thread.Join(TimeSpan.FromSeconds(30))) throw new TimeoutException("LiveView navigation tests timed out.");
+ if (failure is not null) throw new InvalidOperationException("LiveView navigation tests failed.", failure);
+ }
+
+ private static void Assert(bool condition, string message)
+ {
+ if (!condition) throw new InvalidOperationException(message);
+ }
+
+ private static void CheckMatching()
+ {
+ var selected = new LiveViewCaptureDescription("Report", 200, 100);
+ var first = new LiveViewWindowTarget(1, 1, "Report");
+ var second = new LiveViewWindowTarget(2, 2, "Report");
+ Assert(LiveViewWindowMatcher.Match(selected, [first], _ => selected) == first,
+ "One matching description must enable navigation.");
+ Assert(LiveViewWindowMatcher.Match(selected, [], _ => selected) is null,
+ "No candidates must leave navigation disabled.");
+ Assert(LiveViewWindowMatcher.Match(selected, [first, second], _ => selected) is null,
+ "Two matching descriptions must remain ambiguous.");
+ Assert(LiveViewWindowMatcher.Match(selected, [first], _ => selected with { Width = 300 }) is null,
+ "Matching titles with different dimensions must not link.");
+ Assert(LiveViewWindowMatcher.Match(selected, [first], _ => selected with { Title = "Renamed" }) is null,
+ "A title changing during identification must not link.");
+ Assert(LiveViewWindowMatcher.Match(selected, [first, second], candidate =>
+ candidate == first ? selected with { Height = 300 } : selected) == second,
+ "Dimensions must distinguish windows sharing a title.");
+ Assert(LiveViewWindowMatcher.Match(selected, [first with { Title = "Other" }],
+ _ => throw new InvalidOperationException("An unrelated title must not be queried.")) is null,
+ "Unrelated windows must not participate in identification.");
+ Assert(LiveViewWindowMatcher.Match(selected, [first, second], candidate =>
+ candidate == first ? selected : throw new InvalidOperationException("Window closed")) is null,
+ "A candidate failing after a match must disable navigation without throwing.");
+ Assert(LiveViewWindowMatcher.Match(selected with { Title = "" }, [first], _ => selected) is null,
+ "An empty title must never identify a window.");
+ }
+}
diff --git a/tests/SQLBI.Whiteboard.SmokeTests/Program.cs b/tests/SQLBI.Whiteboard.SmokeTests/Program.cs
index 3945d36..b283ff2 100644
--- a/tests/SQLBI.Whiteboard.SmokeTests/Program.cs
+++ b/tests/SQLBI.Whiteboard.SmokeTests/Program.cs
@@ -11,6 +11,13 @@
using SQLBI.Whiteboard.Core.Settings;
using SQLBI.Whiteboard.Export;
+if (args.Contains("--liveview-navigation-only") || args.Contains("--liveview-navigation-capture"))
+{
+ SQLBI.Whiteboard.SmokeTests.LiveViewNavigationSmokeTests.Run(args.Contains("--liveview-navigation-capture"));
+ Console.WriteLine("LiveView navigation smoke tests passed.");
+ return;
+}
+
// The languages Whiteboard colors from a syntax definition rather than a parser.
// What is checked is what a reader sees: which characters carry which color, that
// a construct that spans lines keeps its color to the end, that an unterminated
@@ -590,6 +597,7 @@ public function greet(string $name): string
SQLBI.Whiteboard.SmokeTests.WindowCloseSmokeTests.Run();
SQLBI.Whiteboard.SmokeTests.AutoFullScreenSmokeTests.Run();
SQLBI.Whiteboard.SmokeTests.LiveViewFocusSmokeTests.Run();
+SQLBI.Whiteboard.SmokeTests.LiveViewNavigationSmokeTests.Run();
Console.WriteLine("SQLBI.Whiteboard smoke tests passed.");