diff --git a/Notify/App.axaml b/Notify/App.axaml index 6c82bfc..2c78b80 100644 --- a/Notify/App.axaml +++ b/Notify/App.axaml @@ -10,12 +10,11 @@ - + - - - diff --git a/Notify/App.axaml.cs b/Notify/App.axaml.cs index 0f008b1..9432dae 100644 --- a/Notify/App.axaml.cs +++ b/Notify/App.axaml.cs @@ -57,6 +57,14 @@ public partial class App : Application Notify.Interop.Sound.Play(); } + // 目标窗口已是前台(你正盯着看):完成类通知弹一下即可,用更短的停留 + int? durationOverride = null; + if (!message.InputMode && message.TargetHwnd != 0 && + Notify.Interop.Win32.GetForegroundWindow().ToInt64() == message.TargetHwnd) + { + durationOverride = Settings.Current.FocusedDurationSeconds; + } + Dispatcher.UIThread.Post(() => Toasts.Show(new ToastRequest { Title = message.Title, @@ -66,6 +74,7 @@ public partial class App : Application TargetHwnd = message.TargetHwnd, WtRuntimeId = message.WtRuntimeId, IconPath = message.IconPath, + DurationSecondsOverride = durationOverride, })); } @@ -83,23 +92,10 @@ public partial class App : Application }); } - private int _counter; + // 左键单击托盘图标直接打开设置 + private void OnTrayClicked(object? sender, EventArgs e) => OpenSettings(); - private void OnTestToastClick(object? sender, EventArgs e) - { - _counter++; - var inputMode = _counter % 2 == 0; - Toasts.Show(new ToastRequest - { - Title = inputMode ? "需要你的输入" : "Claude Code", - Message = inputMode - ? $"权限请求 #{_counter} — 点击跳回终端" - : $"任务已完成 #{_counter}", - InputMode = inputMode, - }); - } - - private void OnOpenSettingsClick(object? sender, EventArgs e) + private void OpenSettings() { if (_settingsWindow is { } w) { diff --git a/Notify/Ipc/NotificationSpool.cs b/Notify/Ipc/NotificationSpool.cs index 8b26758..3409150 100644 --- a/Notify/Ipc/NotificationSpool.cs +++ b/Notify/Ipc/NotificationSpool.cs @@ -63,12 +63,15 @@ public static class NotificationSpool return; } + // 必须用 UseShellExecute=true 让 host 彻底脱离本进程的标准句柄 + // 否则常驻 host 会继承并攥住钩子的 stdout 管道,导致 Claude Code + // 等不到管道 EOF 而卡在 "running stop hook" Process.Start(new ProcessStartInfo { FileName = exe, Arguments = "host", - UseShellExecute = false, - CreateNoWindow = true, + UseShellExecute = true, + WindowStyle = ProcessWindowStyle.Hidden, }); } catch diff --git a/Notify/Models/ToastRequest.cs b/Notify/Models/ToastRequest.cs index 8e1b4d2..7c79e4c 100644 --- a/Notify/Models/ToastRequest.cs +++ b/Notify/Models/ToastRequest.cs @@ -33,4 +33,9 @@ public sealed class ToastRequest /// 调用方 App 的 exe 路径,用于显示其图标 /// public string? IconPath { get; init; } + + /// + /// 覆盖停留秒数;为 null 时用设置里的默认值 + /// + public int? DurationSecondsOverride { get; init; } } diff --git a/Notify/Models/ToastSettings.cs b/Notify/Models/ToastSettings.cs index ceeccd7..de2456f 100644 --- a/Notify/Models/ToastSettings.cs +++ b/Notify/Models/ToastSettings.cs @@ -1,14 +1,23 @@ namespace Notify.Models; /// -/// 屏幕角落,决定 toast 堆叠的起点与方向 +/// 水平方向:决定 toast 贴左/居中/贴右 /// -public enum ToastCorner +public enum HEdge { - TopLeft, - TopRight, - BottomLeft, - BottomRight, + Left, + Center, + Right, +} + +/// +/// 垂直方向:决定 toast 贴上/居中/贴下,并决定堆叠方向 +/// +public enum VEdge +{ + Top, + Center, + Bottom, } /// @@ -22,9 +31,24 @@ public sealed class ToastSettings public int DurationSeconds { get; set; } = 4; /// - /// 出现的屏幕角落 + /// 目标窗口已在前台时的停留秒数(你正盯着看,弹一下即可) /// - public ToastCorner Corner { get; set; } = ToastCorner.BottomRight; + public int FocusedDurationSeconds { get; set; } = 2; + + /// + /// 水平方向 + /// + public HEdge Horizontal { get; set; } = HEdge.Right; + + /// + /// 垂直方向 + /// + public VEdge Vertical { get; set; } = VEdge.Bottom; + + /// + /// 与屏幕边缘的留白(DIP) + /// + public int Margin { get; set; } = 12; /// /// 不透明度 0–1 diff --git a/Notify/Services/ToastManager.cs b/Notify/Services/ToastManager.cs index fac9221..4fe0a76 100644 --- a/Notify/Services/ToastManager.cs +++ b/Notify/Services/ToastManager.cs @@ -13,7 +13,6 @@ namespace Notify.Services; /// public sealed class ToastManager { - private const int Margin = 12; private const int Gap = 8; private readonly SettingsService _settings; @@ -33,7 +32,7 @@ public sealed class ToastManager } var vm = new ToastViewModel(request); - var window = new ToastWindow(vm, settings, request.Sticky, request.TargetHwnd, request.WtRuntimeId, request.IconPath); + var window = new ToastWindow(vm, settings, request.Sticky, request.TargetHwnd, request.WtRuntimeId, request.IconPath, request.DurationSecondsOverride); window.Closed += OnToastClosed; _active.Add(window); @@ -78,21 +77,32 @@ public sealed class ToastManager var settings = _settings.Current; var wa = screen.WorkingArea; // 物理像素 var scale = anchor.RenderScaling; - var margin = (int)(Margin * scale); + var margin = (int)(settings.Margin * scale); var gap = (int)(Gap * scale); - var bottom = settings.Corner is ToastCorner.BottomLeft or ToastCorner.BottomRight; - var right = settings.Corner is ToastCorner.TopRight or ToastCorner.BottomRight; - var cursor = bottom ? wa.Bottom - margin : wa.Y + margin; + // 垂直靠下时向上堆叠,否则从锚点向下堆叠 + var stackUp = settings.Vertical == VEdge.Bottom; + var cursor = settings.Vertical switch + { + VEdge.Top => wa.Y + margin, + VEdge.Bottom => wa.Bottom - margin, + _ => wa.Y + wa.Height / 2, + }; foreach (var toast in _active) { var wPx = (int)(toast.Width * scale); var hPx = (int)(toast.Bounds.Height * scale); - var x = right ? wa.Right - margin - wPx : wa.X + margin; + + var x = settings.Horizontal switch + { + HEdge.Left => wa.X + margin, + HEdge.Right => wa.Right - margin - wPx, + _ => wa.X + (wa.Width - wPx) / 2, + }; int y; - if (bottom) + if (stackUp) { cursor -= hPx; y = cursor; diff --git a/Notify/ViewModels/SettingsViewModel.cs b/Notify/ViewModels/SettingsViewModel.cs index 0d8ea23..1ec8c6d 100644 --- a/Notify/ViewModels/SettingsViewModel.cs +++ b/Notify/ViewModels/SettingsViewModel.cs @@ -18,7 +18,10 @@ public partial class SettingsViewModel : ObservableObject var s = settings.Current; DurationSeconds = s.DurationSeconds; - Corner = s.Corner; + FocusedDurationSeconds = s.FocusedDurationSeconds; + Horizontal = s.Horizontal; + Vertical = s.Vertical; + Margin = s.Margin; Opacity = s.Opacity; MaxVisible = s.MaxVisible; PlaySound = s.PlaySound; @@ -27,14 +30,24 @@ public partial class SettingsViewModel : ObservableObject ShowOnAllDesktops = s.ShowOnAllDesktops; } - public IReadOnlyList Corners { get; } = - [ToastCorner.TopLeft, ToastCorner.TopRight, ToastCorner.BottomLeft, ToastCorner.BottomRight]; + public IReadOnlyList Horizontals { get; } = [HEdge.Left, HEdge.Center, HEdge.Right]; + + public IReadOnlyList Verticals { get; } = [VEdge.Top, VEdge.Center, VEdge.Bottom]; [ObservableProperty] public partial int DurationSeconds { get; set; } [ObservableProperty] - public partial ToastCorner Corner { get; set; } + public partial int FocusedDurationSeconds { get; set; } + + [ObservableProperty] + public partial HEdge Horizontal { get; set; } + + [ObservableProperty] + public partial VEdge Vertical { get; set; } + + [ObservableProperty] + public partial int Margin { get; set; } [ObservableProperty] public partial double Opacity { get; set; } @@ -63,7 +76,10 @@ public partial class SettingsViewModel : ObservableObject _settings.Save(new ToastSettings { DurationSeconds = DurationSeconds, - Corner = Corner, + FocusedDurationSeconds = FocusedDurationSeconds, + Horizontal = Horizontal, + Vertical = Vertical, + Margin = Margin, Opacity = Opacity, MaxVisible = MaxVisible, PlaySound = PlaySound, diff --git a/Notify/Views/SettingsWindow.axaml b/Notify/Views/SettingsWindow.axaml index 8a5fc9e..bb182bd 100644 --- a/Notify/Views/SettingsWindow.axaml +++ b/Notify/Views/SettingsWindow.axaml @@ -24,12 +24,26 @@ - - + + + + + + + + + + + + + diff --git a/Notify/Views/ToastWindow.axaml.cs b/Notify/Views/ToastWindow.axaml.cs index c148164..a0b9a52 100644 --- a/Notify/Views/ToastWindow.axaml.cs +++ b/Notify/Views/ToastWindow.axaml.cs @@ -26,17 +26,18 @@ public partial class ToastWindow : Window private bool _closing; // 设计器需要的无参构造 - public ToastWindow() : this(new ToastViewModel(new ToastRequest { Title = "Title", Message = "Message" }), new ToastSettings(), false, 0, null, null) + public ToastWindow() : this(new ToastViewModel(new ToastRequest { Title = "Title", Message = "Message" }), new ToastSettings(), false, 0, null, null, null) { } - public ToastWindow(ToastViewModel vm, ToastSettings settings, bool sticky, long targetHwnd, string? wtRuntimeId, string? iconPath) + public ToastWindow(ToastViewModel vm, ToastSettings settings, bool sticky, long targetHwnd, string? wtRuntimeId, string? iconPath, int? durationOverride) { _settings = settings; _targetHwnd = targetHwnd; _wtRuntimeId = wtRuntimeId; - // 常驻:请求显式 Sticky,或全局停留时长 <= 0 - _sticky = sticky || settings.DurationSeconds <= 0; + var duration = durationOverride ?? settings.DurationSeconds; + // 常驻:请求显式 Sticky,或停留时长 <= 0 + _sticky = sticky || duration <= 0; InitializeComponent(); DataContext = vm; @@ -68,7 +69,7 @@ public partial class ToastWindow : Window _dismissTimer = new DispatcherTimer { - Interval = TimeSpan.FromSeconds(Math.Max(1, settings.DurationSeconds)), + Interval = TimeSpan.FromSeconds(Math.Max(1, duration)), }; _dismissTimer.Tick += (_, _) => BeginClose(); }