feat: 定位模型重做、聚焦短显、托盘交互与 host spawn 修复
- 定位改为 水平(左/中/右) × 垂直(上/中/下) + 边缘留白,取代四角枚举 - 完成类通知在目标窗口已聚焦时用更短停留(FocusedDurationSeconds) - 托盘左键单击直接打开设置,右键菜单仅保留退出 - 修复 host 拉起用 UseShellExecute=true,脱离钩子 stdout 管道,避免 Claude 卡在 running stop hook
This commit is contained in:
+3
-4
@@ -10,12 +10,11 @@
|
||||
|
||||
<TrayIcon.Icons>
|
||||
<TrayIcons>
|
||||
<TrayIcon Icon="/Assets/claude.ico" ToolTipText="Claude Code Notify">
|
||||
<TrayIcon Icon="/Assets/claude.ico"
|
||||
ToolTipText="Claude Code Notify"
|
||||
Clicked="OnTrayClicked">
|
||||
<TrayIcon.Menu>
|
||||
<NativeMenu>
|
||||
<NativeMenuItem Header="测试弹窗" Click="OnTestToastClick" />
|
||||
<NativeMenuItem Header="设置…" Click="OnOpenSettingsClick" />
|
||||
<NativeMenuItemSeparator />
|
||||
<NativeMenuItem Header="退出" Click="OnExitClick" />
|
||||
</NativeMenu>
|
||||
</TrayIcon.Menu>
|
||||
|
||||
+12
-16
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,4 +33,9 @@ public sealed class ToastRequest
|
||||
/// 调用方 App 的 exe 路径,用于显示其图标
|
||||
/// </summary>
|
||||
public string? IconPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 覆盖停留秒数;为 null 时用设置里的默认值
|
||||
/// </summary>
|
||||
public int? DurationSecondsOverride { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
namespace Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 屏幕角落,决定 toast 堆叠的起点与方向
|
||||
/// 水平方向:决定 toast 贴左/居中/贴右
|
||||
/// </summary>
|
||||
public enum ToastCorner
|
||||
public enum HEdge
|
||||
{
|
||||
TopLeft,
|
||||
TopRight,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 垂直方向:决定 toast 贴上/居中/贴下,并决定堆叠方向
|
||||
/// </summary>
|
||||
public enum VEdge
|
||||
{
|
||||
Top,
|
||||
Center,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -22,9 +31,24 @@ public sealed class ToastSettings
|
||||
public int DurationSeconds { get; set; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// 出现的屏幕角落
|
||||
/// 目标窗口已在前台时的停留秒数(你正盯着看,弹一下即可)
|
||||
/// </summary>
|
||||
public ToastCorner Corner { get; set; } = ToastCorner.BottomRight;
|
||||
public int FocusedDurationSeconds { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// 水平方向
|
||||
/// </summary>
|
||||
public HEdge Horizontal { get; set; } = HEdge.Right;
|
||||
|
||||
/// <summary>
|
||||
/// 垂直方向
|
||||
/// </summary>
|
||||
public VEdge Vertical { get; set; } = VEdge.Bottom;
|
||||
|
||||
/// <summary>
|
||||
/// 与屏幕边缘的留白(DIP)
|
||||
/// </summary>
|
||||
public int Margin { get; set; } = 12;
|
||||
|
||||
/// <summary>
|
||||
/// 不透明度 0–1
|
||||
|
||||
@@ -13,7 +13,6 @@ namespace Notify.Services;
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
@@ -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<ToastCorner> Corners { get; } =
|
||||
[ToastCorner.TopLeft, ToastCorner.TopRight, ToastCorner.BottomLeft, ToastCorner.BottomRight];
|
||||
public IReadOnlyList<HEdge> Horizontals { get; } = [HEdge.Left, HEdge.Center, HEdge.Right];
|
||||
|
||||
public IReadOnlyList<VEdge> 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,
|
||||
|
||||
@@ -24,12 +24,26 @@
|
||||
<u:NumericIntUpDown Value="{Binding DurationSeconds}" Minimum="1" Maximum="60" />
|
||||
</u:FormItem>
|
||||
|
||||
<u:FormItem Label="出现角落">
|
||||
<ComboBox ItemsSource="{Binding Corners}"
|
||||
SelectedItem="{Binding Corner}"
|
||||
<u:FormItem Label="聚焦时停留(秒)">
|
||||
<u:NumericIntUpDown Value="{Binding FocusedDurationSeconds}" Minimum="1" Maximum="60" />
|
||||
</u:FormItem>
|
||||
|
||||
<u:FormItem Label="水平方向">
|
||||
<ComboBox ItemsSource="{Binding Horizontals}"
|
||||
SelectedItem="{Binding Horizontal}"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</u:FormItem>
|
||||
|
||||
<u:FormItem Label="垂直方向">
|
||||
<ComboBox ItemsSource="{Binding Verticals}"
|
||||
SelectedItem="{Binding Vertical}"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</u:FormItem>
|
||||
|
||||
<u:FormItem Label="边缘留白(DIP)">
|
||||
<u:NumericIntUpDown Value="{Binding Margin}" Minimum="0" Maximum="200" Step="2" />
|
||||
</u:FormItem>
|
||||
|
||||
<u:FormItem Label="不透明度">
|
||||
<u:NumericDoubleUpDown Value="{Binding Opacity}" Minimum="0.3" Maximum="1.0" Step="0.05" />
|
||||
</u:FormItem>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user