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
55 changes: 43 additions & 12 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,7 @@ numbers can go straight there:
- **The shell** — [reskin phase 1](#system-font--inter-wins-only-because-fluent-asks-for-it-first)
(system theme, accent and font, the "Decided and shipped" paragraph at the end of the font section)
and [reskin phase 2](#as-shipped-reskin-phase-2--the-window-shell): the 440×700 native-decorated
window, the layer stack, the opaque-root and toolbar-inset rules.
window, the layer stack, and the opaque-root, toolbar-inset and drag-handle rules.
- **The clock** — the "one timer" paragraph under
[reskin phase 2](#as-shipped-reskin-phase-2--the-window-shell): `UiClock`, the hygiene test that
keeps `DispatcherTimer` out, and the elapsed-time progress bar.
Expand Down Expand Up @@ -982,12 +982,22 @@ works exactly as intended, with no options to set beyond the hint itself:
| `ActualTransparencyLevel` (empty hint) | `None` |

Reproduced identically across runs. The red 48 px content band runs to the top of the window and the
traffic lights are drawn on top of it, with the window still moving by that strip — the "art bleeds up
behind the traffic lights, system buttons stay" arrangement the Linux paragraph above says does not
exist there. That last sentence is an on-screen observation, not a property read, and no image is
committed for it (see the screenshot note at the end of this section); the numbers in the table are
what the row actually rests on. So the intent stands unchanged on macOS and has to be given up on Linux, which makes the
top strip a per-platform layout decision rather than a shared one.
traffic lights are drawn on top of it — the "art bleeds up behind the traffic lights, system buttons
stay" arrangement the Linux paragraph above says does not exist there. So the intent stands unchanged
on macOS and has to be given up on Linux, which makes the top strip a per-platform layout decision
rather than a shared one.

**The window does not move by that strip.** This row used to claim it did; re-running `ShellSpike
chrome` on macOS 26.6.2 with Avalonia 12.1.1 — the property table above unchanged, `Position=840,390`
— pressing and dragging the strip leaves the window exactly where it is. Extending the client area is
the whole of it: it leaves no native title bar, and the spike's own opaque content band is
hit-testable and covers the strip, so Avalonia takes the press and AppKit never sees a window drag.
Anything that wants the window movable has to call `BeginMoveDrag` itself, which is what the shipped
shell does (see "As shipped", below); the same reading is what makes that possible, since a press
being eaten is a press that arrived. The traffic lights are unaffected — AppKit draws them above the
client area and hit-tests them first. These are on-screen observations, not property reads, and no
image is committed for them (see the screenshot note at the end of this section); the numbers in the
table are what the row actually rests on.

**Windows 11 Mica — unmeasured.** Intended: `TransparencyLevelHint="Mica, None"`
(`WindowTransparencyLevel.Mica` first, `None` as the pre-22H2 fallback — the property is an ordered
Expand Down Expand Up @@ -1159,19 +1169,40 @@ code-behind on `OperatingSystem.IsX()`: **Linux** sets nothing (the client-area
KWin, above); **Windows** sets `TransparencyLevelHint = [Mica, None]`; **macOS** sets
`ExtendClientAreaToDecorationsHint = true` and `ExtendClientAreaTitleBarHeightHint = -1` with no custom
caption buttons, and the client area is deliberately *not* extended on Windows until the system caption
buttons are known to survive it. Two rules are then read back from the window's own properties as they
buttons are known to survive it. Three rules are then read back from the window's own properties as they
change (`OnPropertyChanged`, never `Opened`, where the Wayland head still reports the fallback variant
and scaling): the **opaque root** — the bottom `Border` of the stack, painted with Fluent's
`SystemControlBackgroundAltHighBrush`, the colour the veil token derives from — is at 100 % opacity
unless `ActualTransparencyLevel` settles on `Mica`, when it drops to 35 % so the material shows through;
and the **toolbar inset** follows `WindowDecorationMargin` and `IsExtendedIntoWindowDecorations`, taking
the **toolbar inset** follows `WindowDecorationMargin` and `IsExtendedIntoWindowDecorations`, taking
the strip's height on top plus a fixed 78 px on the left for the traffic lights, which no property
reports. Both are null-guarded because the Wayland backend raises
reports. Reading that height rather than assuming it earns its keep: the shipped app reports
`0,32,0,0` where the spike above measured `0,28,0,0`, in the same OS and Avalonia build. The cause is
not established — the two differ in bundle and in window size — and nothing here needs it to be, since
no code carries the figure; and the **drag handle**, because extending the client area leaves no native title bar to move
the window by and the strip it uncovers does not move the window either (above). The handle is the whole
toolbar `Border`, given a transparent background — a null one is not hit-testable, which is exactly why
the toolbar has no drag surface at all where the decorations are native. Its `PointerPressed` handler
calls `BeginMoveDrag` behind a pure static,
`BeginsWindowDrag(isExtendedIntoDecorations, isLeftButton, pointer, decorationMargin)`: extended
decorations, the primary button (macOS opens the window's own menu on a secondary press in the title
area), and a press outside the traffic-light cluster. That last one is its own static,
`IsOverTrafficLights`, over the pointer and `WindowDecorationMargin` in the window's own coordinates —
the strip's height by `TrafficLightsWidth`, so the 78 px lives in one place and the rectangle is not a
layout margin being read as a region. AppKit hit-tests the buttons first, so a press there should never
reach the handler; the carve-out does not depend on that. AppKit keeps the resize edges as well:
with the handle spanning the toolbar down to `y = 0`, dragging the top edge or the top-right corner
still resizes the window rather than moving it — measured on the shipped app — so a press only ever
begins a move on a pixel AppKit has already declined. The press is left to
bubble and is *not* taken `handledEventsToo` or in the tunnel phase, so the settings `ToggleButton` marks
its own press handled and the gear keeps toggling instead of starting a drag.
All three are null-guarded because the Wayland backend raises
`IsExtendedIntoWindowDecorations` from the base `Window` constructor, before the XAML has loaded. Above
the root, bottom to top: the blurred-art `Image` (Phase 3), the ambient layer (Phase 5, a `Panel` — see there),
the `VeilBrush` `Border` visible only when either backdrop is, and the content grid of toolbar, body
and footer. `Sendspin.Ui.Tests/MainWindowShellTests` finds each layer by name and pins the opacity rule
for both cases. Screenshots: `docs/screenshots/reskin/phase2-{wayland,x11}-{light,dark}.png`.
and footer. `Sendspin.Ui.Tests/MainWindowShellTests` finds each layer by name, pins the opacity rule
for both cases, and pins the drag gate and its traffic-light carve-out — `BeginMoveDrag`
itself cannot be exercised headlessly, the decision in front of it can. Screenshots: `docs/screenshots/reskin/phase2-{wayland,x11}-{light,dark}.png`.

**The one timer.** `Player/Threading/UiClock.cs` is a `System.Threading.Timer` posting one tick to
`Dispatcher.UIThread` at `Render` priority, dropping a tick while the previous one is still queued
Expand Down
20 changes: 20 additions & 0 deletions docs/NEXT_STEPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,26 @@ still needed at the boundary.

---

## 10. Double-click the macOS toolbar to zoom

The macOS window is movable by its toolbar (`MainWindow.OnToolbarPointerPressed`, and the
drag-handle rule in `ARCHITECTURE.md`'s "As shipped (reskin phase 2)"). Double-clicking a title bar
to zoom, the other half of what a native Mac title bar does, was asked for at the same time and
deliberately left out.

macOS has a system preference for what a title-bar double-click does — zoom, minimise, or nothing
(`AppleActionOnDoubleClick`) — and Avalonia 12 exposes no way to read it. Setting
`WindowState = Maximized` on a double-click would hardcode one of those three answers and put an
unmeasured platform assumption into the one section of `ARCHITECTURE.md` where every other claim is
a measurement.

**First action:** a `ShellSpike chrome` case that sets `WindowState = Maximized` on macOS and
records what actually happens to the frame, then the behaviour behind an "As shipped" paragraph
that says so. Reading the preference itself needs a P/Invoke to `CFPreferencesCopyAppValue`, which
is only worth it if the spike shows `Maximized` is the wrong answer for two of the three settings.

---

## Things that are done and should not be reopened

Recorded so the reasoning is not relitigated from scratch:
Expand Down
6 changes: 4 additions & 2 deletions src/Sendspin.Player/Styles/PlayerStyles.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@
</Styles.Resources>

<!--
Surfaces. The toolbar paints nothing of its own so the backdrop can run up behind it; the
footer and the settings card sit on the translucent surface token. Both rows use the
Surfaces. The toolbar paints nothing of its own so the backdrop can run up behind it — and
its Background belongs to MainWindow's code-behind, which makes it the window's drag handle
on macOS, so a Background setter added here would be outranked there and clobbered elsewhere;
the footer and the settings card sit on the translucent surface token. Both rows use the
iconButton at 36 px rather than the transport's 48 (see the buttons below), which is what
keeps them slim.
-->
Expand Down
8 changes: 6 additions & 2 deletions src/Sendspin.Player/Views/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,13 @@
<!-- Layer 2: the content. -->
<Grid Name="ContentLayer" RowDefinitions="Auto,*,Auto">

<!-- Toolbar: the connection line, and the settings toggle. -->
<!--
Toolbar: the connection line, and the settings toggle. Its background is the
code-behind's — transparent, so hit-testable, where the client area was taken into
the decorations, which is what makes the whole toolbar the window's drag handle.
-->
<Border Grid.Row="0" Name="Toolbar" Classes="toolbar">
<!-- ToolbarContent's margin is the code-behind's: the macOS title strip inset. -->
<!-- ToolbarContent's margin is the code-behind's too: the macOS title strip inset. -->
<Grid Name="ToolbarContent" ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Name="ConnectionLine"
Expand Down
75 changes: 69 additions & 6 deletions src/Sendspin.Player/Views/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System.ComponentModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Microsoft.Extensions.DependencyInjection;
using Sendspin.Core.Configuration;
using Sendspin.Player.ViewModels;
Expand Down Expand Up @@ -28,8 +30,11 @@ namespace Sendspin.Player.Views;
/// caption buttons survive that is still unverified.
/// </description></item>
/// <item><description>
/// macOS: the client area extends into the title strip (measured 28 px), and the toolbar insets
/// its content by that strip and leaves room for the traffic lights.
/// macOS: the client area extends into the title strip, whose height is read from the window
/// rather than assumed — this app reports 32 px where the spike measured 28 — and the toolbar
/// insets its content by that strip and leaves room for the traffic lights. The toolbar is also
/// the window's drag handle: extending the client area leaves no native title bar to move the
/// window by, and the strip it uncovers does not move the window either.
/// </description></item>
/// </list>
/// <para>
Expand All @@ -55,8 +60,7 @@ public sealed partial class MainWindow : Window
/// <summary>
/// The width of the traffic-light cluster the toolbar leaves clear when it runs into the
/// title strip. <see cref="Window.WindowDecorationMargin"/> reports only the strip's height
/// (<c>0,28,0,0</c> measured), not the buttons' width, so this is the one figure not read
/// from a property.
/// not the buttons' width, so this is the one figure not read from a property.
/// </summary>
internal const double TrafficLightsWidth = 78;

Expand All @@ -66,6 +70,9 @@ public sealed partial class MainWindow : Window
public MainWindow()
{
InitializeComponent();
// Wired here rather than in the axaml because the surface it drags by is the code-behind's
// too (see UpdateToolbarInset): both halves of the handle stay in one place.
Toolbar.PointerPressed += OnToolbarPointerPressed;
ApplyPlatformHints();
UpdateRootOpacity();
UpdateToolbarInset();
Expand All @@ -87,6 +94,34 @@ internal static Thickness ToolbarInsetFor(bool isExtendedIntoDecorations, Thickn
? new Thickness(decorationMargin.Left + TrafficLightsWidth, decorationMargin.Top, decorationMargin.Right, 0)
: default;

/// <summary>
/// Whether <paramref name="pointer"/>, in the window's own coordinates, is over the
/// traffic-light cluster: the strip's height by the cluster's width, in the corner
/// <paramref name="decorationMargin"/> starts from.
/// </summary>
/// <remarks>
/// AppKit draws the buttons above the client area and hit-tests them first, so a press there
/// should never reach this window's handlers at all. Carving the rectangle out costs nothing
/// and does not rely on that staying true.
/// </remarks>
internal static bool IsOverTrafficLights(Point pointer, Thickness decorationMargin) =>
pointer.X < decorationMargin.Left + TrafficLightsWidth && pointer.Y < decorationMargin.Top;

/// <summary>
/// Whether a press at <paramref name="pointer"/> should move the window.
/// </summary>
/// <remarks>
/// The client area has to have been taken into the decorations, since that is what leaves no
/// native title bar to grab. The press has to be the primary button: macOS opens the window's
/// own menu on a secondary press in the title area, and no backend expects a move drag from
/// one. And it has to miss the traffic lights.
/// </remarks>
internal static bool BeginsWindowDrag(
bool isExtendedIntoDecorations, bool isLeftButton, Point pointer, Thickness decorationMargin) =>
isExtendedIntoDecorations
&& isLeftButton
&& !IsOverTrafficLights(pointer, decorationMargin);

/// <summary>
/// Hides to the tray, or asks the application to shut down, depending on the user's choice.
/// </summary>
Expand Down Expand Up @@ -262,9 +297,37 @@ private void UpdateRootOpacity()

private void UpdateToolbarInset()
{
if (ToolbarContent is { } toolbar)
if (ToolbarContent is { } content)
{
content.Margin = ToolbarInsetFor(IsExtendedIntoWindowDecorations, WindowDecorationMargin);
}

// The drag surface is the whole toolbar Border, not the inset content: the title strip is
// the part of it the inset holds clear, and the strip does not drag by itself either. A
// null background is not hit-testable, so where the decorations are native the toolbar has
// no drag surface at all. The surface and the inset are one fact, so they move together.
if (Toolbar is { } toolbar)
{
toolbar.Background = IsExtendedIntoWindowDecorations ? Brushes.Transparent : null;
}
}

/// <summary>
/// Moves the window by its toolbar. Bubbling, and without <c>handledEventsToo</c>, so that
/// <see cref="SettingsButton"/> — a <see cref="Primitives.ToggleButton"/>, which marks the
/// press handled — keeps toggling Settings instead of starting a drag.
/// </summary>
private void OnToolbarPointerPressed(object? sender, PointerPressedEventArgs e)
{
var pointer = e.GetCurrentPoint(this);

if (BeginsWindowDrag(
IsExtendedIntoWindowDecorations,
pointer.Properties.IsLeftButtonPressed,
pointer.Position,
WindowDecorationMargin))
{
toolbar.Margin = ToolbarInsetFor(IsExtendedIntoWindowDecorations, WindowDecorationMargin);
BeginMoveDrag(e);
}
}
}
Loading