using System.Diagnostics;
using System.Drawing.Drawing2D;
using System.Drawing.Text;
namespace MoveReminder;
internal enum ReminderOutcome
{
/// <summary>用户点了弹窗(确认知道了)。</summary>
Dismissed,
/// <summary>用户点了「稍后再提醒」。</summary>
Snoozed,
/// <summary>无人理会,倒计时结束自动消失。</summary>
TimedOut,
}
/// <summary>
/// 桌面右下角弹出的提醒卡片:圆角、滑入淡入动画、
/// 顶部是在线加载的精美图片,下面是鼓励标语,底部有倒计时进度条。
/// 不抢焦点(WS_EX_NOACTIVATE),正在打字时也不会被打断。
/// </summary>
internal sealed class ReminderForm : Form
{
// 逻辑像素(96 DPI)下的布局常量
private const int CardW = 400;
private const int CardH = 340;
private const int ImageH = 172;
private const float CornerRadius = 14f;
private readonly AppConfig _cfg;
private readonly string _title;
private readonly string _message;
private readonly float _scale;
private readonly CancellationTokenSource _cts = new();
private readonly Stopwatch _clock = Stopwatch.StartNew();
private readonly System.Windows.Forms.Timer _timer;
private readonly Font _titleFont;
private readonly Font _bodyFont;
private readonly Font _smallFont;
private Image? _photo;
private bool _loadingPhoto;
private RectangleF _rectPrimary;
private RectangleF _rectSecondary;
private RectangleF _rectClose;
private bool _hoverCard;
private bool _hoverPrimary;
private bool _hoverSecondary;
private bool _hoverClose;
private TimeSpan _lastFrame;
private double _pausedMs;
private double _animT;
private bool _closing;
private bool _disposed;
private Point _targetLocation;
private Point _entryLocation;
private Point _exitLocation;
public ReminderOutcome Outcome { get; private set; } = ReminderOutcome.TimedOut;
public ReminderForm(AppConfig cfg)
{
_cfg = cfg;
SetStyle(
ControlStyles.AllPaintingInWmPaint
| ControlStyles.OptimizedDoubleBuffer
| ControlStyles.UserPaint
| ControlStyles.ResizeRedraw,
true);
DoubleBuffered = true;
FormBorderStyle = FormBorderStyle.None;
StartPosition = FormStartPosition.Manual;
ShowInTaskbar = false;
TopMost = true;
MinimizeBox = false;
MaximizeBox = false;
KeyPreview = false;
Text = "久坐提醒";
Cursor = Cursors.Hand;
_scale = DeviceDpi / 96f;
Width = (int)Math.Round(CardW * _scale);
Height = (int)Math.Round(CardH * _scale);
_titleFont = new Font("Microsoft YaHei UI", 23f * _scale, FontStyle.Bold, GraphicsUnit.Pixel);
_bodyFont = new Font("Microsoft YaHei UI", 14.5f * _scale, FontStyle.Regular, GraphicsUnit.Pixel);
_smallFont = new Font("Microsoft YaHei UI", 12f * _scale, FontStyle.Regular, GraphicsUnit.Pixel);
_title = Quotes.NextTitle();
_message = PickFittingMessage();
LayoutButtons();
using (var path = AppIcons.RoundedRect(new RectangleF(0, 0, Width, Height), CornerRadius * _scale))
{
Region = new Region(path);
}
// 关键:在窗口句柄创建之前就把位置和透明度定好。
// 否则窗口会先在屏幕 (0,0) 闪一下,再跳到右下角。
ComputeLocations();
Location = _entryLocation;
Opacity = 0.0;
_timer = new System.Windows.Forms.Timer { Interval = 20 };
_timer.Tick += OnFrame;
}
/// <summary>挑一条能放进两行内的标语,太长就换下一条。</summary>
private string PickFittingMessage()
{
float maxWidth = (CardW - 44) * _scale;
string best = Quotes.NextEncouragement();
// 用屏幕 DC 测量,避免在构造函数里提前创建窗口句柄。
using var g = Graphics.FromHwnd(IntPtr.Zero);
for (int i = 0; i < 8; i++)
{
string candidate = Quotes.NextEncouragement();
var size = g.MeasureString(candidate, _bodyFont, (int)maxWidth);
if (size.Width <= maxWidth + 1 && size.Height <= 44 * _scale)
{
return candidate;
}
if (best.Length > candidate.Length)
{
best = candidate;
}
}
return best;
}
private float S => _scale;
/// <summary>设置 MOVEREMINDER_DEBUG=1 时,把弹窗行为写进 %TEMP%\MoveReminder-debug.log。</summary>
private static void DebugLog(string message)
{
if (!string.Equals(Environment.GetEnvironmentVariable("MOVEREMINDER_DEBUG"), "1", StringComparison.Ordinal))
{
return;
}
try
{
File.AppendAllText(
Path.Combine(Path.GetTempPath(), "MoveReminder-debug.log"),
$"[{DateTime.Now:HH:mm:ss.fff}] {message}\r\n");
}
catch
{
// 忽略。
}
}
private void LayoutButtons()
{
float btnH = 34 * S;
float btnY = 290 * S;
_rectPrimary = new RectangleF(284 * S, btnY, 94 * S, btnH);
_rectSecondary = new RectangleF(146 * S, btnY, 128 * S, btnH);
_rectClose = new RectangleF(Width - 38 * S, 12 * S, 26 * S, 26 * S);
}
protected override bool ShowWithoutActivation => true;
protected override CreateParams CreateParams
{
get
{
var cp = base.CreateParams;
cp.ExStyle |= 0x08000000; // WS_EX_NOACTIVATE:不抢焦点
cp.ExStyle |= 0x00000080; // WS_EX_TOOLWINDOW:不出现在 Alt+Tab
return cp;
}
}
protected override void OnShown(EventArgs e)
{
base.OnShown(e);
Location = _entryLocation;
Opacity = 0.0;
TopMost = true; // 再确认一次置顶,避免被全屏或其它置顶窗口盖住
DebugLog($"shown size={Width}x{Height} entry={_entryLocation} target={_targetLocation} exit={_exitLocation}");
_lastFrame = _clock.Elapsed;
_timer.Start();
if (_cfg.PlaySound)
{
try
{
System.Media.SystemSounds.Asterisk.Play();
}
catch
{
// 静音环境无所谓。
}
}
_ = LoadPhotoAsync();
}
/// <summary>
/// 计算右下角的目标位置,以及进出场动画的起止点。
/// 滑动距离刻意做得很短(几十像素),这样在多显示器下动画不会跨到隔壁屏幕上去。
/// </summary>
private void ComputeLocations()
{
// 跟随鼠标所在的那块屏幕,多显示器下也能出现在正确的右下角。
var screen = Screen.FromPoint(Cursor.Position);
var work = screen.WorkingArea;
int margin = (int)Math.Round(16 * S);
_targetLocation = new Point(work.Right - Width - margin, work.Bottom - Height - margin);
int slideX = (int)Math.Round(38 * S);
int slideY = (int)Math.Round(18 * S);
_entryLocation = new Point(_targetLocation.X + slideX, _targetLocation.Y + slideY);
_exitLocation = new Point(_targetLocation.X + slideX, _targetLocation.Y);
}
private async Task LoadPhotoAsync()
{
if (!_cfg.LoadOnlineImage)
{
return;
}
_loadingPhoto = true;
try
{
var image = await ImageFetcher.FetchAsync(_cfg, _cts.Token).ConfigureAwait(true);
if (IsDisposed || !IsHandleCreated)
{
image?.Dispose();
return;
}
if (image is not null)
{
_photo?.Dispose();
_photo = image;
Invalidate();
}
}
catch
{
// 加载失败就用占位渐变图。
}
finally
{
_loadingPhoto = false;
if (!IsDisposed && IsHandleCreated)
{
Invalidate();
}
}
}
private void OnFrame(object? sender, EventArgs e)
{
if (IsDisposed)
{
return;
}
var now = _clock.Elapsed;
double dt = Math.Max(0, (now - _lastFrame).TotalSeconds);
_lastFrame = now;
if (_closing)
{
AdvanceExitAnimation(dt);
return;
}
AdvanceEntryAnimation(dt);
UpdateCountdown(dt);
if (_photo is null && _loadingPhoto)
{
Invalidate();
}
}
private void AdvanceEntryAnimation(double dt)
{
if (_animT >= 1.0)
{
return;
}
_animT = Math.Min(1.0, _animT + dt / 0.30);
double eased = 1 - Math.Pow(1 - _animT, 3); // easeOutCubic
Location = new Point(
_entryLocation.X + (int)Math.Round((_targetLocation.X - _entryLocation.X) * eased),
_entryLocation.Y + (int)Math.Round((_targetLocation.Y - _entryLocation.Y) * eased));
Opacity = Math.Min(1.0, eased * 1.25);
}
private void AdvanceExitAnimation(double dt)
{
_animT = Math.Min(1.0, _animT + dt / 0.20);
double eased = _animT * _animT; // easeInQuad
Location = new Point(
_targetLocation.X + (int)Math.Round((_exitLocation.X - _targetLocation.X) * eased),
_targetLocation.Y);
Opacity = Math.Max(0.0, 1.0 - eased * 1.4);
if (_animT >= 1.0)
{
DebugLog($"exit animation finished, closing (outcome={Outcome})");
_timer.Stop();
Close();
}
}
private void UpdateCountdown(double dt)
{
if (_animT < 1.0)
{
Invalidate();
return;
}
double elapsed = _clock.Elapsed.TotalSeconds;
// 鼠标停在卡片上时暂停倒计时,但总时长最多放宽到 3 倍,避免一直赖着不走。
if (_hoverCard)
{
_pausedMs += dt * 1000.0;
}
double remaining = _cfg.AutoCloseSeconds - (elapsed - _pausedMs / 1000.0);
if (remaining <= 0 || elapsed > _cfg.AutoCloseSeconds * 3.0)
{
BeginClose(ReminderOutcome.TimedOut);
return;
}
Invalidate();
}
private void BeginClose(ReminderOutcome outcome)
{
if (_closing)
{
return;
}
DebugLog($"BeginClose({outcome}) animT was {_animT:0.00}");
Outcome = outcome;
_closing = true;
_animT = 0;
_lastFrame = _clock.Elapsed;
_hoverCard = false;
}
private void RequestSnooze() => BeginClose(ReminderOutcome.Snoozed);
// ---------- 绘制 ----------
protected override void OnPaintBackground(PaintEventArgs e)
{
// 全部在 OnPaint 里自绘,避免闪烁。
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
g.SmoothingMode = SmoothingMode.AntiAlias;
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
g.TextRenderingHint = TextRenderingHint.AntiAliasGridFit;
var card = new RectangleF(0, 0, Width, Height);
using (var cardPath = AppIcons.RoundedRect(card, CornerRadius * S))
using (var bg = new SolidBrush(Color.White))
{
g.FillPath(bg, cardPath);
}
DrawPhoto(g);
// 图片与内容区之间的分隔
using (var sep = new Pen(Color.FromArgb(235, 238, 242), Math.Max(1f, S)))
{
g.DrawLine(sep, 0, ImageH * S, Width, ImageH * S);
}
DrawTexts(g);
DrawButtons(g);
DrawProgress(g);
// 一圈很淡的描边,让卡片边缘更利落
using (var cardPath = AppIcons.RoundedRect(
new RectangleF(0.5f * S, 0.5f * S, Width - S, Height - S),
CornerRadius * S))
using (var border = new Pen(Color.FromArgb(30, 15, 23, 42), Math.Max(1f, S)))
{
g.DrawPath(border, cardPath);
}
}
private void DrawPhoto(Graphics g)
{
var area = new RectangleF(0, 0, Width, ImageH * S);
var clip = new Rectangle(0, 0, Width, (int)Math.Round(ImageH * S));
var oldClip = g.Clip;
g.SetClip(clip);
if (_photo is not null)
{
DrawCover(g, _photo, area);
}
else
{
DrawPlaceholder(g, area);
}
g.Clip = oldClip;
// 图片底部压一层柔和渐变,让画面与白色内容区衔接自然
using var fade = new LinearGradientBrush(
new RectangleF(0, clip.Bottom - 34 * S, Width, 34 * S),
Color.FromArgb(0, 0, 0, 0),
Color.FromArgb(38, 0, 0, 0),
LinearGradientMode.Vertical);
g.FillRectangle(fade, new RectangleF(0, clip.Bottom - 34 * S, Width, 34 * S));
DrawCloseButton(g);
}
private static void DrawCover(Graphics g, Image image, RectangleF dest)
{
float scale = Math.Max(dest.Width / image.Width, dest.Height / image.Height);
float w = image.Width * scale;
float h = image.Height * scale;
float x = dest.X + (dest.Width - w) / 2f;
float y = dest.Y + (dest.Height - h) / 2f;
g.DrawImage(image, new RectangleF(x, y, w, h));
}
private void DrawPlaceholder(Graphics g, RectangleF area)
{
// 每次弹窗随机一组好看的撞色渐变
(Color a, Color b)[] palettes =
{
(Color.FromArgb(99, 102, 241), Color.FromArgb(139, 92, 246)),
(Color.FromArgb(14, 165, 233), Color.FromArgb(34, 211, 238)),
(Color.FromArgb(16, 185, 129), Color.FromArgb(20, 184, 166)),
(Color.FromArgb(245, 158, 11), Color.FromArgb(239, 68, 68)),
(Color.FromArgb(236, 72, 153), Color.FromArgb(168, 85, 247)),
};
var (c1, c2) = palettes[Math.Abs(GetHashCode()) % palettes.Length];
using (var brush = new LinearGradientBrush(area, c1, c2, 35f))
{
g.FillRectangle(brush, area);
}
float cx = area.Width / 2f;
float cy = area.Height / 2f;
float radius = 17 * S;
if (_loadingPhoto)
{
using var ring = new Pen(Color.FromArgb(70, 255, 255, 255), 3.2f * S)
{
StartCap = LineCap.Round,
EndCap = LineCap.Round,
};
float angle = (float)(_clock.Elapsed.TotalMilliseconds / 3.0 % 360.0);
g.DrawArc(ring, cx - radius, cy - radius - 8 * S, radius * 2, radius * 2, angle, 280);
using var font = new Font("Microsoft YaHei UI", 13f * S, FontStyle.Regular, GraphicsUnit.Pixel);
using var fmt = new StringFormat
{
Alignment = StringAlignment.Center,
LineAlignment = StringAlignment.Center,
};
using var text = new SolidBrush(Color.FromArgb(225, 255, 255, 255));
g.DrawString("正在加载精美图片...", font, text, new RectangleF(0, cy + 22 * S, area.Width, 26 * S), fmt);
}
else
{
using var font = new Font("Microsoft YaHei UI", 34f * S, FontStyle.Bold, GraphicsUnit.Pixel);
using var fmt = new StringFormat
{
Alignment = StringAlignment.Center,
LineAlignment = StringAlignment.Center,
};
using var text = new SolidBrush(Color.FromArgb(235, 255, 255, 255));
g.DrawString("起 身 动 一 动", font, text, area, fmt);
}
}
private void DrawTexts(Graphics g)
{
using var titleBrush = new SolidBrush(Color.FromArgb(17, 24, 39));
using var bodyBrush = new SolidBrush(Color.FromArgb(75, 85, 99));
using var tipBrush = new SolidBrush(Color.FromArgb(107, 114, 128));
using (var fmt = new StringFormat
{
Alignment = StringAlignment.Near,
LineAlignment = StringAlignment.Center,
Trimming = StringTrimming.EllipsisCharacter,
FormatFlags = StringFormatFlags.NoWrap,
})
{
g.DrawString(_title, _titleFont, titleBrush, new RectangleF(22 * S, 184 * S, (CardW - 44) * S, 32 * S), fmt);
}
using (var fmt = new StringFormat
{
Alignment = StringAlignment.Near,
LineAlignment = StringAlignment.Near,
})
{
g.DrawString(_message, _bodyFont, bodyBrush, new RectangleF(22 * S, 220 * S, (CardW - 44) * S, 46 * S), fmt);
}
// 倒计时剩余秒数
double remaining = Math.Max(0, _cfg.AutoCloseSeconds - (_clock.Elapsed.TotalSeconds - _pausedMs / 1000.0));
string hint = _hoverCard ? $"已暂停 · {remaining:0} 秒" : $"{remaining:0} 秒后自动关闭";
using (var fmt = new StringFormat
{
Alignment = StringAlignment.Near,
LineAlignment = StringAlignment.Center,
FormatFlags = StringFormatFlags.NoWrap,
})
{
g.DrawString(hint, _smallFont, tipBrush, new RectangleF(22 * S, 290 * S, 118 * S, 34 * S), fmt);
}
}
private void DrawButtons(Graphics g)
{
// 主按钮
using (var path = AppIcons.RoundedRect(_rectPrimary, 9 * S))
using (var brush = new SolidBrush(_hoverPrimary
? Color.FromArgb(5, 150, 105)
: Color.FromArgb(16, 185, 129)))
{
g.FillPath(brush, path);
}
DrawCenteredText(g, "这就去 ✓", _rectPrimary, Color.White, _bodyFont);
// 次按钮
if (_hoverSecondary)
{
using var path = AppIcons.RoundedRect(_rectSecondary, 9 * S);
using var brush = new SolidBrush(Color.FromArgb(243, 244, 246));
g.FillPath(brush, path);
}
using (var path = AppIcons.RoundedRect(
new RectangleF(_rectSecondary.X + 0.5f * S, _rectSecondary.Y + 0.5f * S,
_rectSecondary.Width - S, _rectSecondary.Height - S), 9 * S))
using (var pen = new Pen(Color.FromArgb(209, 213, 219), Math.Max(1f, S)))
{
g.DrawPath(pen, path);
}
DrawCenteredText(g, $"{_cfg.SnoozeMinutes} 分钟后再提醒", _rectSecondary,
Color.FromArgb(55, 65, 81), _smallFont);
}
private void DrawCloseButton(Graphics g)
{
using var brush = new SolidBrush(_hoverClose
? Color.FromArgb(190, 0, 0, 0)
: Color.FromArgb(110, 0, 0, 0));
g.FillEllipse(brush, _rectClose);
using var pen = new Pen(Color.White, 1.7f * S)
{
StartCap = LineCap.Round,
EndCap = LineCap.Round,
};
float pad = 8.4f * S;
g.DrawLine(pen, _rectClose.Left + pad, _rectClose.Top + pad, _rectClose.Right - pad, _rectClose.Bottom - pad);
g.DrawLine(pen, _rectClose.Right - pad, _rectClose.Top + pad, _rectClose.Left + pad, _rectClose.Bottom - pad);
}
private void DrawCenteredText(Graphics g, string text, RectangleF rect, Color color, Font font)
{
using var fmt = new StringFormat
{
Alignment = StringAlignment.Center,
LineAlignment = StringAlignment.Center,
FormatFlags = StringFormatFlags.NoWrap,
};
using var brush = new SolidBrush(color);
g.DrawString(text, font, brush, rect, fmt);
}
private void DrawProgress(Graphics g)
{
float trackY = Height - 4 * S;
float trackH = 4 * S;
using (var track = new SolidBrush(Color.FromArgb(241, 245, 249)))
{
g.FillRectangle(track, 0, trackY, Width, trackH);
}
double remaining = Math.Max(0, _cfg.AutoCloseSeconds - (_clock.Elapsed.TotalSeconds - _pausedMs / 1000.0));
double ratio = Math.Clamp(remaining / Math.Max(1, _cfg.AutoCloseSeconds), 0, 1);
if (ratio <= 0)
{
return;
}
Color barColor = _hoverCard
? Color.FromArgb(148, 163, 184)
: ratio < 0.25
? Color.FromArgb(245, 158, 11)
: Color.FromArgb(16, 185, 129);
using var brush = new SolidBrush(barColor);
g.FillRectangle(brush, 0, trackY, (float)(Width * ratio), trackH);
}
// ---------- 交互 ----------
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
bool hp = _rectPrimary.Contains(e.Location);
bool hs = _rectSecondary.Contains(e.Location);
bool hc = _rectClose.Contains(e.Location);
bool hover = !_hoverCard || hp != _hoverPrimary || hs != _hoverSecondary || hc != _hoverClose;
_hoverCard = true;
_hoverPrimary = hp;
_hoverSecondary = hs;
_hoverClose = hc;
if (hover)
{
Invalidate();
}
}
protected override void OnMouseLeave(EventArgs e)
{
base.OnMouseLeave(e);
_hoverCard = false;
_hoverPrimary = false;
_hoverSecondary = false;
_hoverClose = false;
Invalidate();
}
protected override void OnMouseUp(MouseEventArgs e)
{
base.OnMouseUp(e);
DebugLog($"mouseup button={e.Button} at={e.Location} closing={_closing} animT={_animT:0.00} " +
$"secondary={_rectSecondary.Contains(e.Location)} close={_rectClose.Contains(e.Location)} " +
$"primary={_rectPrimary.Contains(e.Location)}");
if (e.Button == MouseButtons.Right)
{
RequestSnooze();
return;
}
if (e.Button != MouseButtons.Left || _closing)
{
return;
}
if (_rectClose.Contains(e.Location))
{
BeginClose(ReminderOutcome.Dismissed);
}
else if (_rectSecondary.Contains(e.Location))
{
RequestSnooze();
}
else
{
// 点卡片任意位置都算「我知道了」
BeginClose(ReminderOutcome.Dismissed);
}
}
protected override void OnDeactivate(EventArgs e)
{
base.OnDeactivate(e);
// 不抢焦点,所以这里不做任何事。
}
protected override void Dispose(bool disposing)
{
if (disposing && !_disposed)
{
_disposed = true;
try
{
_timer.Stop();
_timer.Dispose();
_cts.Cancel();
_cts.Dispose();
_photo?.Dispose();
_titleFont.Dispose();
_bodyFont.Dispose();
_smallFont.Dispose();
}
catch
{
// 关闭阶段不要因为清理异常影响退出。
}
}
base.Dispose(disposing);
}
}