From 3fdb78b08ed59c7ad72dcab94e67e763985b7f84 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 00:26:39 +0000 Subject: [PATCH 1/5] Open the mini panel by the tray icon, on the screen it was clicked on The panel took the main screen's corner, so with the notification area on another monitor it opened on the wrong screen. A click on the icon now reads the pointer's place on Windows and puts the panel against that taskbar, centred on the click within the work area. Without a click on a bar it keeps the corner rule, on the clicked screen. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KXdvwJ9Y3S9ThVsMtuVQSS --- CHANGELOG.md | 3 ++ src/Neap.Core/MiniPanel.cs | 37 +++++++++++++----- src/Neap.Desktop/MainWindow.Tray.cs | 23 ++++++++++- src/Neap.Desktop/MiniPanelWindow.axaml.cs | 16 +++++--- tests/Neap.Core.Tests/MiniPanelTests.cs | 47 +++++++++++++++++++---- 5 files changed, 103 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e0494b0..5234fd72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,9 @@ 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 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/MainWindow.Tray.cs b/src/Neap.Desktop/MainWindow.Tray.cs index 8bd64975..a3b0c065 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; @@ -106,7 +107,7 @@ private void ToggleMini() return; } AppLog.Write("mini panel: opened from the notification area"); - var panel = new MiniPanelWindow { Main = this }; + var panel = new MiniPanelWindow { Main = this, Click = PointerAt() }; panel.Closed += (_, _) => { _miniClosedAt = panel.ClosedAt; @@ -117,6 +118,26 @@ 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; + + [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.cs b/src/Neap.Desktop/MiniPanelWindow.axaml.cs index 6e76e160..f2400102 100644 --- a/src/Neap.Desktop/MiniPanelWindow.axaml.cs +++ b/src/Neap.Desktop/MiniPanelWindow.axaml.cs @@ -75,6 +75,7 @@ public MiniPanelWindow() Close(); }; SizeChanged += (_, _) => Place(); + ScalingChanged += (_, _) => Place(); } /// @@ -91,6 +92,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; } @@ -143,16 +147,18 @@ 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)); + (int)Math.Ceiling(Bounds.Width * scale), (int)Math.Ceiling(Bounds.Height * scale), (int)Math.Round(EdgeGap * scale), at); MaxHeight = box.Height / scale; - var at = new PixelPoint(box.X, box.Y); - if (Position != at) Position = at; + 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/tests/Neap.Core.Tests/MiniPanelTests.cs b/tests/Neap.Core.Tests/MiniPanelTests.cs index 34c9a24c..918dd32f 100644 --- a/tests/Neap.Core.Tests/MiniPanelTests.cs +++ b/tests/Neap.Core.Tests/MiniPanelTests.cs @@ -7,39 +7,72 @@ public class MiniPanelTests [Fact] public void BesideATaskbarAtTheFootItIsBottomRight() => Assert.Equal(new Box(1568, 570, 340, 450), - MiniPanel.Place(Screen, new Box(0, 0, 1920, 1032), 340, 450, 12)); + MiniPanel.Place(Screen, new Box(0, 0, 1920, 1032), 340, 450, 12, null)); [Fact] public void BesideATaskbarAtTheTopItIsTopRight() => Assert.Equal(new Box(1568, 60, 340, 450), - MiniPanel.Place(Screen, new Box(0, 48, 1920, 1032), 340, 450, 12)); + MiniPanel.Place(Screen, new Box(0, 48, 1920, 1032), 340, 450, 12, null)); [Fact] public void BesideATaskbarOnTheRightItIsBottomRight() => Assert.Equal(new Box(1520, 618, 340, 450), - MiniPanel.Place(Screen, new Box(0, 0, 1872, 1080), 340, 450, 12)); + MiniPanel.Place(Screen, new Box(0, 0, 1872, 1080), 340, 450, 12, null)); [Fact] public void BesideATaskbarOnTheLeftItIsBottomLeft() => Assert.Equal(new Box(60, 618, 340, 450), - MiniPanel.Place(Screen, new Box(48, 0, 1872, 1080), 340, 450, 12)); + MiniPanel.Place(Screen, new Box(48, 0, 1872, 1080), 340, 450, 12, null)); [Fact] public void WithNoTaskbarToFindItIsBottomRight() => - Assert.Equal(new Box(1568, 618, 340, 450), MiniPanel.Place(Screen, Screen, 340, 450, 12)); + Assert.Equal(new Box(1568, 618, 340, 450), MiniPanel.Place(Screen, Screen, 340, 450, 12, null)); [Fact] public void OnASecondScreenItStaysOnThatScreen() { var second = new Box(1920, 0, 2560, 1440); Assert.Equal(new Box(4128, 930, 340, 450), - MiniPanel.Place(second, new Box(1920, 0, 2560, 1392), 340, 450, 12)); + MiniPanel.Place(second, new Box(1920, 0, 2560, 1392), 340, 450, 12, null)); } [Fact] public void ItNeverGrowsPastTheWorkArea() => Assert.Equal(new Box(12, 12, 676, 376), - MiniPanel.Place(new Box(0, 0, 700, 400), new Box(0, 0, 700, 400), 800, 800, 12)); + MiniPanel.Place(new Box(0, 0, 700, 400), new Box(0, 0, 700, 400), 800, 800, 12, null)); + + [Fact] + public void AClickOnATaskbarAtTheFootPutsItAboveTheClick() => + Assert.Equal(new Box(1430, 570, 340, 450), + MiniPanel.Place(Screen, new Box(0, 0, 1920, 1032), 340, 450, 12, (1600, 1056))); + + [Fact] + public void AClickOnATaskbarAtTheTopPutsItBelowTheClick() => + Assert.Equal(new Box(830, 60, 340, 450), + MiniPanel.Place(Screen, new Box(0, 48, 1920, 1032), 340, 450, 12, (1000, 20))); + + [Fact] + public void AClickNearTheEndOfTheTaskbarKeepsItInsideTheWorkArea() => + Assert.Equal(new Box(1568, 570, 340, 450), + MiniPanel.Place(Screen, new Box(0, 0, 1920, 1032), 340, 450, 12, (1900, 1056))); + + [Fact] + public void AClickOnATaskbarOnTheLeftPutsItBesideTheClick() => + Assert.Equal(new Box(60, 400, 340, 450), + MiniPanel.Place(Screen, new Box(48, 0, 1872, 1080), 340, 450, 12, (24, 625))); + + [Fact] + public void AClickOnATaskbarOnASecondScreenPutsItThereAboveTheClick() + { + var second = new Box(1920, 0, 2560, 1440); + Assert.Equal(new Box(4030, 930, 340, 450), + MiniPanel.Place(second, new Box(1920, 0, 2560, 1392), 340, 450, 12, (4200, 1416))); + } + + [Fact] + public void AClickInsideTheWorkAreaLeavesItInTheCorner() => + Assert.Equal(new Box(1568, 570, 340, 450), + MiniPanel.Place(Screen, new Box(0, 0, 1920, 1032), 340, 450, 12, (900, 500))); [Fact] public void AClickOpensAClosedPanel() => From 9944778418920ba16e533a0ed85e3509d1802fd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 01:10:27 +0000 Subject: [PATCH 2/5] Fade the mini panel in and out The panel appeared and vanished at once, which reads as abrupt beside the system's own flyouts. The card now fades up into place as it opens and fades away as it closes; a close waits for the fade unless the app is stopping. Both follow Windows' animation setting through Motion. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KXdvwJ9Y3S9ThVsMtuVQSS --- CHANGELOG.md | 2 + src/Neap.Desktop/MiniPanelWindow.axaml | 2 +- src/Neap.Desktop/MiniPanelWindow.axaml.cs | 53 +++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5234fd72..8bf724a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,8 @@ and date. - 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. - 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.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 f2400102..c15bd48a 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() { @@ -106,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, @@ -119,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); @@ -131,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) { From a54ceca1e7afdcef9d941cd77185cf7ef7a1e3a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 01:55:11 +0000 Subject: [PATCH 3/5] Keep a settings row's name on one line In the mini panel, the microphone's switch carries its reason, such as "Muted by the boom arm", and the row keeps every control beside its name, so the name gave way until "Microphone" broke mid-word. A row now puts its control under the name when, side by side, the name would no longer fit on one line. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KXdvwJ9Y3S9ThVsMtuVQSS --- CHANGELOG.md | 3 +++ src/Neap.Desktop/Controls/SettingsCard.cs | 31 +++++++++++++++++++++-- src/Neap.Desktop/Themes/Neap.axaml | 7 ++--- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf724a6..e4e3167e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,9 @@ and date. 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.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/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 @@