diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7e0494b0..a0903f5c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,9 @@ and date.
### Added
+- A keyboard shortcut opens and closes the mini panel, Ctrl + Alt + M by
+ default, beside the mix's shortcuts and changed the same way. It opens the
+ panel by the clock, on the screen whose taskbar holds it.
- The mini panel has spatial sound, between noise cancellation and the
equaliser: Off, Windows Sonic or Dolby Atmos, whichever the system offers. It
is left out where the system has none, and it stays when the headset is away,
@@ -73,6 +76,14 @@ and date.
### Changed
+- On Windows, the mini panel opens by Neap's icon, on the screen whose taskbar
+ holds it, rather than in the corner of the main screen. Opened from the
+ icon's menu, it goes in the corner of the screen the menu was on.
+- The mini panel fades up into place as it opens and fades away as it
+ closes. With "Show animations in Windows" off, it appears and goes at once.
+- A settings row whose control says more than a switch, such as the
+ microphone's "Muted by the boom arm" in the mini panel, puts the control
+ under the name rather than breaking the name mid-word.
- The app's text is set in Figtree, and the name beside the mark in Outfit,
in place of Inter. Both are carried inside the app, so Windows and Linux
show the same letters. The text is a little rounder and a little wider.
diff --git a/src/Neap.Core/MiniPanel.cs b/src/Neap.Core/MiniPanel.cs
index 575c0e79..e47798ff 100644
--- a/src/Neap.Core/MiniPanel.cs
+++ b/src/Neap.Core/MiniPanel.cs
@@ -13,13 +13,20 @@ public enum TrayClick
/// Decides where the mini panel opens, what a click on the tray icon does, and whether it opens the panel.
///
///
-/// The panel opens beside the notification area, as the system's own volume
-/// flyout does. A desktop does not say where its icons are, but it does say
-/// where its taskbar is: the work area is the screen less the bars, so the
-/// side a bar takes shows as a gap between the two. The panel goes in the
-/// corner next to that bar, on the side the icons are on: the right, except
-/// beside a bar on the left edge. With no bar to find, as with one that hides
-/// itself or a desktop that reports none, it goes bottom right.
+/// The panel opens by the icon that was clicked. A desktop does not say where
+/// its icons are, but a click on one lands on a taskbar, outside the work
+/// area, which is the screen less its bars. So a click on a bar puts the panel
+/// against that bar, centred on the click as far as the work area allows,
+/// which keeps it by the icon on whichever screen the notification area is.
+///
+///
+/// Without such a click, as from the icon's menu or on a desktop that does not
+/// say where the pointer is, the panel goes in a corner, as the system's own
+/// volume flyout does. The side a bar takes shows as a gap between the screen
+/// and its work area, and the panel goes in the corner next to that bar, on
+/// the side the icons are on: the right, except beside a bar on the left edge.
+/// With no bar to find, as with one that hides itself or a desktop that
+/// reports none, it goes bottom right.
///
///
/// Clicking the icon while the panel is open first takes focus from the
@@ -38,13 +45,23 @@ public static class MiniPanel
/// The panel's width, in pixels.
/// The panel's height, in pixels.
/// The gap kept to the work area's edges, in pixels.
- public static Box Place(Box screen, Box work, int width, int height, int margin)
+ /// Where the pointer was when the icon was clicked, or null when that is not known.
+ public static Box Place(Box screen, Box work, int width, int height, int margin, (int X, int Y)? click)
{
int w = Math.Min(width, work.Width - 2 * margin), h = Math.Min(height, work.Height - 2 * margin);
+ int right = work.X + work.Width - w - margin, bottom = work.Y + work.Height - h - margin;
+ if (click is { } c)
+ {
+ if (c.Y < work.Y || c.Y >= work.Y + work.Height)
+ return new(Math.Clamp(c.X - w / 2, work.X + margin, right), c.Y < work.Y ? work.Y + margin : bottom, w, h);
+ if (c.X < work.X || c.X >= work.X + work.Width)
+ return new(c.X < work.X ? work.X + margin : right, Math.Clamp(c.Y - h / 2, work.Y + margin, bottom), w, h);
+ }
+
bool top = work.Y > screen.Y && work.Y + work.Height == screen.Y + screen.Height;
bool left = work.X > screen.X && work.X + work.Width == screen.X + screen.Width;
- int x = left ? work.X + margin : work.X + work.Width - w - margin;
- int y = top ? work.Y + margin : work.Y + work.Height - h - margin;
+ int x = left ? work.X + margin : right;
+ int y = top ? work.Y + margin : bottom;
return new(x, y, w, h);
}
diff --git a/src/Neap.Desktop/Controls/MixKeysPanel.axaml b/src/Neap.Desktop/Controls/MixKeysPanel.axaml
index 742f216e..8938c902 100644
--- a/src/Neap.Desktop/Controls/MixKeysPanel.axaml
+++ b/src/Neap.Desktop/Controls/MixKeysPanel.axaml
@@ -30,6 +30,9 @@
+
+
+
diff --git a/src/Neap.Desktop/Controls/MixKeysPanel.axaml.cs b/src/Neap.Desktop/Controls/MixKeysPanel.axaml.cs
index 558d3249..a18edbb4 100644
--- a/src/Neap.Desktop/Controls/MixKeysPanel.axaml.cs
+++ b/src/Neap.Desktop/Controls/MixKeysPanel.axaml.cs
@@ -7,8 +7,8 @@
namespace Neap.Desktop.Controls;
///
-/// The keys that move the game and chat mix from anywhere, and a way to
-/// change them.
+/// The keys that move the game and chat mix or open the mini panel from
+/// anywhere, and a way to change them.
///
///
///
@@ -49,6 +49,7 @@ public MixKeysPanel()
Capture(GameKey, MixKey.TowardGame);
Capture(ChatKey, MixKey.TowardChat);
Capture(CentreKey, MixKey.Balanced);
+ Capture(MiniKey, MixKey.MiniPanel);
}
protected override void OnLoaded(RoutedEventArgs e)
@@ -144,6 +145,7 @@ private void Paint()
Show(GameKey, GameRow, MixKey.TowardGame);
Show(ChatKey, ChatRow, MixKey.TowardChat);
Show(CentreKey, CentreRow, MixKey.Balanced);
+ Show(MiniKey, MiniRow, MixKey.MiniPanel);
_painting = true;
KeysOn.IsChecked = AppServices.Hotkeys.Enabled;
diff --git a/src/Neap.Desktop/Controls/SettingsCard.cs b/src/Neap.Desktop/Controls/SettingsCard.cs
index f4e82230..dd46757d 100644
--- a/src/Neap.Desktop/Controls/SettingsCard.cs
+++ b/src/Neap.Desktop/Controls/SettingsCard.cs
@@ -63,7 +63,8 @@ public Geometry? Icon
///
/// The width below which the control moves under the name, so neither is
/// squeezed. A panel whose rows are short, like the mini panel, sets it
- /// to zero in a style to keep a switch beside its name.
+ /// to zero in a style to keep a switch beside its name while the name
+ /// still fits on one line.
///
public double SideBySideWidth
{
@@ -75,12 +76,16 @@ public double SideBySideWidth
private Grid? _layout;
private Control? _control;
+ private Control? _glyph;
+ private Control? _header;
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
_layout = e.NameScope.Find("Layout");
_control = e.NameScope.Find("Control");
+ _glyph = e.NameScope.Find("Glyph");
+ _header = e.NameScope.Find("HeaderText");
Place(Bounds.Width);
}
@@ -113,7 +118,7 @@ protected override Size MeasureOverride(Size availableSize)
private void Place(double width)
{
if (_layout is null || _control is null || width <= 0) return;
- bool beside = width >= SideBySideWidth;
+ bool beside = width >= SideBySideWidth && NameFitsBeside(width);
Grid.SetRow(_control, beside ? 0 : 1);
Grid.SetColumn(_control, beside ? 2 : 1);
Grid.SetColumnSpan(_control, beside ? 1 : 2);
@@ -128,4 +133,26 @@ private void Place(double width)
// Likewise the gap above it when it sits under the name, so a row with nothing under it has no spare room.
_control.Margin = beside ? new Thickness(Space.M, 0, 0, 0) : new Thickness(0, Space.S, 0, 0);
}
+
+ /// Whether the name keeps to one line with the control beside it.
+ ///
+ /// A control that says more than a switch, such as the reason the microphone
+ /// cannot be changed, can leave a short name a few letters wide, broken
+ /// mid-word. Then the control goes under the name instead.
+ ///
+ private bool NameFitsBeside(double width)
+ {
+ if (_header is null || _control is null) return true;
+ _header.Measure(Size.Infinity);
+ _control.Measure(Size.Infinity);
+ double glyph = 0;
+ if (_glyph is { IsVisible: true })
+ {
+ _glyph.Measure(Size.Infinity);
+ glyph = _glyph.DesiredSize.Width;
+ }
+ double edges = Padding.Left + Padding.Right + BorderThickness.Left + BorderThickness.Right;
+ double control = _control.DesiredSize.Width - _control.Margin.Left - _control.Margin.Right;
+ return edges + glyph + _header.DesiredSize.Width + Space.M + control <= width;
+ }
}
diff --git a/src/Neap.Desktop/MainWindow.Tray.cs b/src/Neap.Desktop/MainWindow.Tray.cs
index 8bd64975..dfece074 100644
--- a/src/Neap.Desktop/MainWindow.Tray.cs
+++ b/src/Neap.Desktop/MainWindow.Tray.cs
@@ -1,5 +1,6 @@
using System.ComponentModel;
using System.Diagnostics;
+using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Avalonia;
using Avalonia.Controls;
@@ -58,7 +59,7 @@ private void StartTray()
var open = new NativeMenuItem(Strings.Get("Main_TrayOpen"));
open.Click += (_, _) => Reveal();
var mini = new NativeMenuItem(Strings.Get("Main_TrayMini"));
- mini.Click += (_, _) => ToggleMini();
+ mini.Click += (_, _) => ToggleMini(PointerAt());
mini.IsVisible = AppSettings.Current.MiniPanel;
_miniItem = mini;
var quit = new NativeMenuItem(Strings.Get("Main_TrayQuit"));
@@ -69,11 +70,8 @@ private void StartTray()
Menu = new NativeMenu { open, mini, new NativeMenuItemSeparator(), quit },
};
if (Application.Current?.PlatformSettings is { } settings) settings.ColorValuesChanged += RepaintTrayPicture;
- _tray.Clicked += (_, _) =>
- {
- if (MiniPanel.Click(AppSettings.Current.MiniPanel) == TrayClick.ToggleMiniPanel) ToggleMini();
- else Reveal();
- };
+ _tray.Clicked += (_, _) => Summon(PointerAt());
+ AppServices.Hotkeys.MiniPanelPressed += () => Summon(NotificationArea());
if (Application.Current is { } app) TrayIcon.SetIcons(app, new TrayIcons { _tray });
AppServices.Updates.Found += release => Dispatcher.UIThread.Post(() => TellAboutUpdate(release));
@@ -92,21 +90,29 @@ public void MiniPanelSettingChanged()
if (!on) _mini?.Close();
}
- /// Opens the mini panel, or closes it when it is open.
+ /// Does what a click on the icon or the mini panel's key does: toggles the panel while it is on, or else brings the window forward.
+ private void Summon(PixelPoint? at)
+ {
+ if (MiniPanel.Click(AppSettings.Current.MiniPanel) == TrayClick.ToggleMiniPanel) ToggleMini(at);
+ else Reveal();
+ }
+
+ /// Opens the mini panel by the given point, or closes it when it is open.
+ /// Where the icon was clicked, or the notification area for its key; null puts it in the main screen's corner.
///
/// A click on the icon takes focus from an open panel, which closes it
/// before the click arrives; tells that
/// click apart from one that asks to open it.
///
- private void ToggleMini()
+ private void ToggleMini(PixelPoint? click)
{
if (!MiniPanel.Opens(_mini is not null, Stopwatch.GetElapsedTime(_miniClosedAt)))
{
_mini?.Close();
return;
}
- AppLog.Write("mini panel: opened from the notification area");
- var panel = new MiniPanelWindow { Main = this };
+ AppLog.Write("mini panel: opened");
+ var panel = new MiniPanelWindow { Main = this, Click = click };
panel.Closed += (_, _) =>
{
_miniClosedAt = panel.ClosedAt;
@@ -117,6 +123,68 @@ private void ToggleMini()
panel.Activate();
}
+ /// Where the pointer is on the desktop, in physical pixels, or null where that cannot be read.
+ ///
+ /// The icon's click carries no place, and the toolkit has no pointer
+ /// outside its own windows, so Windows is asked. Elsewhere the panel goes
+ /// in the main screen's corner.
+ ///
+ private static PixelPoint? PointerAt() =>
+ OperatingSystem.IsWindows() && GetCursorPos(out var at) ? new PixelPoint(at.X, at.Y) : null;
+
+ /// The clock's end of the main taskbar, in physical pixels, or null where that cannot be read.
+ ///
+ /// A key press has no pointer on the taskbar to open by, so the panel goes
+ /// by the clock instead: on the taskbar that holds the notification area,
+ /// whichever screen that is. Windows gives that taskbar's place but not the
+ /// clock's, which sits at its right end, or its foot when it stands upright.
+ ///
+ private static PixelPoint? NotificationArea()
+ {
+ var bar = new AppBarData { Size = (uint)Marshal.SizeOf() };
+ if (!OperatingSystem.IsWindows() || SHAppBarMessage(GetTaskbarPosition, ref bar) == 0) return null;
+ var r = bar.Rect;
+ return r.Right - r.Left >= r.Bottom - r.Top
+ ? new PixelPoint(r.Right - 1, (r.Top + r.Bottom) / 2)
+ : new PixelPoint((r.Left + r.Right) / 2, r.Bottom - 1);
+ }
+
+ private const uint GetTaskbarPosition = 5;
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct NativeRect
+ {
+ public int Left;
+ public int Top;
+ public int Right;
+ public int Bottom;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct AppBarData
+ {
+ public uint Size;
+ public IntPtr Window;
+ public uint CallbackMessage;
+ public uint Edge;
+ public NativeRect Rect;
+ public IntPtr Param;
+ }
+
+ [DllImport("shell32.dll")]
+ private static extern UIntPtr SHAppBarMessage(uint message, ref AppBarData data);
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct NativePoint
+ {
+ public int X;
+ public int Y;
+ }
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool GetCursorPos(out NativePoint point);
+
/// The notification-area glyph: light on a dark taskbar, dark on a light one.
///
/// Windows has a taskbar shade, set apart from the app's own theme. A Linux
diff --git a/src/Neap.Desktop/MiniPanelWindow.axaml b/src/Neap.Desktop/MiniPanelWindow.axaml
index acf92e93..d5cc0130 100644
--- a/src/Neap.Desktop/MiniPanelWindow.axaml
+++ b/src/Neap.Desktop/MiniPanelWindow.axaml
@@ -31,7 +31,7 @@
in a window of its own so it can sit by the notification area and
go away when it loses focus.
-->
-
+
diff --git a/src/Neap.Desktop/MiniPanelWindow.axaml.cs b/src/Neap.Desktop/MiniPanelWindow.axaml.cs
index 6e76e160..1faa27a3 100644
--- a/src/Neap.Desktop/MiniPanelWindow.axaml.cs
+++ b/src/Neap.Desktop/MiniPanelWindow.axaml.cs
@@ -1,11 +1,15 @@
using System.Diagnostics;
using System.Globalization;
using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
using Avalonia.Automation;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input;
+using Avalonia.Media;
using Avalonia.Media.Imaging;
+using Avalonia.Styling;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Neap.Core;
@@ -33,6 +37,11 @@ namespace Neap.Desktop;
/// went elsewhere meanwhile.
///
///
+/// It fades up into place as it opens and fades away as it closes, as the
+/// system's flyouts do, unless animation is off; see .
+/// A close waits for the fade, except when the app is stopping.
+///
+///
/// A pretend run with shows it alone as the app's
/// only window, which stays open so it can be looked at or pictured.
///
@@ -42,10 +51,18 @@ public partial class MiniPanelWindow : Window
/// The gap kept between the panel and the edge of the work area, in device-independent pixels.
private const double EdgeGap = 12;
+ /// How far the card moves as it fades in or out, in device-independent pixels.
+ private const double Rise = 12;
+
+ private static readonly TimeSpan Arrive = TimeSpan.FromMilliseconds(180);
+ private static readonly TimeSpan Leave = TimeSpan.FromMilliseconds(120);
+
private readonly MainWindow? _main;
private readonly BatteryMeter _battery = new("caption");
private IDisposable? _recheck;
private bool _painting;
+ private bool _leaving;
+ private bool _left;
public MiniPanelWindow()
{
@@ -75,6 +92,7 @@ public MiniPanelWindow()
Close();
};
SizeChanged += (_, _) => Place();
+ ScalingChanged += (_, _) => Place();
}
///
@@ -91,6 +109,9 @@ public MainWindow? Main
}
}
+ /// Gets where the pointer was when the icon was clicked, in physical pixels, or null when that is not known.
+ public PixelPoint? Click { get; init; }
+
/// Gets when it closed, as a timestamp, so the icon's next click can tell whether that click was the one that did it.
public long ClosedAt { get; private set; }
@@ -102,6 +123,7 @@ protected override void OnOpened(EventArgs e)
AppServices.Mix.Changed += Paint;
Paint();
Place();
+ if (Motion.Enabled) _ = Fade(0, 1, Rise, 0, Arrive, new CubicEaseOut());
// Focus begins on the dial, on the way to choose chat apps while the dial waits for
// them, or else on the first thing that can take it. Given without the focus ring,
@@ -115,6 +137,15 @@ protected override void OnOpened(EventArgs e)
if (Pretend.Snapshot is { } file) _ = SaveAndExit(file);
}
+ /// Holds the close until the card has faded out, unless animation is off or the app is stopping.
+ protected override void OnClosing(WindowClosingEventArgs e)
+ {
+ base.OnClosing(e);
+ if (_left || !Motion.Enabled || e.CloseReason is WindowCloseReason.OSShutdown or WindowCloseReason.ApplicationShutdown) return;
+ e.Cancel = true;
+ if (!_leaving) _ = FadeAndClose();
+ }
+
protected override void OnClosed(EventArgs e)
{
base.OnClosed(e);
@@ -127,6 +158,32 @@ protected override void OnClosed(EventArgs e)
private void OnStatus(HeadsetStatus status) => Paint();
+ private async Task FadeAndClose()
+ {
+ _leaving = true;
+ Card.IsHitTestVisible = false;
+ await Fade(1, 0, 0, Rise / 2, Leave, new CubicEaseIn());
+ _left = true;
+ Close();
+ }
+
+ /// Moves the card's opacity and its offset below its place from one value to another.
+ private Task Fade(double fromOpacity, double toOpacity, double fromOffset, double toOffset, TimeSpan duration, Easing easing)
+ {
+ Card.RenderTransform ??= new TranslateTransform();
+ return new Animation
+ {
+ Duration = duration,
+ Easing = easing,
+ FillMode = FillMode.Both,
+ Children =
+ {
+ new KeyFrame { Cue = new Cue(0), Setters = { new Setter(OpacityProperty, fromOpacity), new Setter(TranslateTransform.YProperty, fromOffset) } },
+ new KeyFrame { Cue = new Cue(1), Setters = { new Setter(OpacityProperty, toOpacity), new Setter(TranslateTransform.YProperty, toOffset) } },
+ },
+ }.RunAsync(Card);
+ }
+
/// Opens the main window, on a page if one is given, and puts this away.
private void OpenWindow(string? page)
{
@@ -143,16 +200,20 @@ private void CloseIfAway()
Close();
}
- /// Puts the panel in the corner by the notification area, and keeps it there as it grows or shrinks.
+ /// Puts the panel by the icon, on the screen the icon was clicked on, and keeps it there as it grows or shrinks.
private void Place()
{
- if (Screens.Primary is not { } screen || Bounds.Height <= 0) return;
+ var clicked = Click is { } click ? Screens.ScreenFromPoint(click) : null;
+ if ((clicked ?? Screens.Primary) is not { } screen || Bounds.Height <= 0) return;
double scale = RenderScaling;
+ (int, int)? at = clicked is not null && Click is { } c ? (c.X, c.Y) : null;
var box = MiniPanel.Place(Of(screen.Bounds), Of(screen.WorkingArea),
- (int)Math.Ceiling(Bounds.Width * scale), (int)Math.Ceiling(Bounds.Height * scale), (int)Math.Round(EdgeGap * scale));
- MaxHeight = box.Height / scale;
- var at = new PixelPoint(box.X, box.Y);
- if (Position != at) Position = at;
+ (int)Math.Ceiling(Bounds.Width * scale), (int)Math.Ceiling(Bounds.Height * scale), (int)Math.Round(EdgeGap * scale), at);
+ // Capped by the work area, never by the height it had: a row that grows after
+ // it opens, such as a switch moving under its name, needs the room.
+ MaxHeight = (screen.WorkingArea.Height / scale) - (2 * EdgeGap);
+ var position = new PixelPoint(box.X, box.Y);
+ if (Position != position) Position = position;
static Box Of(PixelRect rect) => new(rect.X, rect.Y, rect.Width, rect.Height);
}
diff --git a/src/Neap.Desktop/Strings/en-US/Resources.resw b/src/Neap.Desktop/Strings/en-US/Resources.resw
index 9b54de65..76132b3c 100644
--- a/src/Neap.Desktop/Strings/en-US/Resources.resw
+++ b/src/Neap.Desktop/Strings/en-US/Resources.resw
@@ -172,7 +172,7 @@
Keyboard shortcuts
- Move the mix while you play.
+ Move the mix or open the mini panel while you play.Keyboard shortcuts
@@ -186,8 +186,11 @@
Back to balanced
+
+ Open the mini panel
+
- Ctrl + Alt + Page Down, Page Up and Home.
+ Ctrl + Alt + Page Down, Page Up, Home and M.Default shortcuts
diff --git a/src/Neap.Desktop/Themes/Neap.axaml b/src/Neap.Desktop/Themes/Neap.axaml
index 4baa0f38..e2c54d5c 100644
--- a/src/Neap.Desktop/Themes/Neap.axaml
+++ b/src/Neap.Desktop/Themes/Neap.axaml
@@ -69,7 +69,8 @@