Skip to content

Commit 187e427

Browse files
committed
Add screenshots and foreground hook for flyout
Add screenshot assets and galleries to README and docs (CSS and HTML) with a CSS lightbox; tweak README wording. Introduce Win32 foreground APIs and a global EVENT_SYSTEM_FOREGROUND hook in Win32Helper (GetForegroundWindow, SetForegroundWindow, SetWinEventHook, UnhookWinEvent). Update TrayFlyout to use the window handle, set foreground when showing, install/uninstall the foreground-change hook, and ignore immediate foreground events via a short grace period so the flyout doesn't self-dismiss right after being shown. Misc: apply window chrome/ DPI fixes and ensure proper resource cleanup.
1 parent 8f2abae commit 187e427

10 files changed

Lines changed: 242 additions & 30 deletions

README.md

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,30 @@
1919

2020
A Windows 11 native eye-break reminder.
2121

22+
<p align="center">
23+
<a href="docs/screenshots/flyout-countdown.png"><img src="docs/screenshots/flyout-countdown.png" width="180" alt="Flyout: countdown"></a>
24+
<a href="docs/screenshots/flyout-snoozed.png"><img src="docs/screenshots/flyout-snoozed.png" width="180" alt="Flyout: snoozed"></a>
25+
<a href="docs/screenshots/short-break-popup.png"><img src="docs/screenshots/short-break-popup.png" width="180" alt="Short break popup"></a>
26+
<a href="docs/screenshots/short-break-countdown.png"><img src="docs/screenshots/short-break-countdown.png" width="180" alt="Short break: countdown"></a>
27+
<a href="docs/screenshots/long-break-popup.png"><img src="docs/screenshots/long-break-popup.png" width="180" alt="Long break popup"></a>
28+
</p>
29+
30+
<sub align="center"><i>Click any screenshot to enlarge.</i></sub>
31+
32+
2233
## What is the 20/20/20 rule?
2334

2435
The 20/20/20 rule is a [proven technique](https://pubmed.ncbi.nlm.nih.gov/36473088/) to prevent eye strain, dryness and degradation.
2536

2637
## Twenti
2738

2839
Built with **WinUI 3** (Windows App SDK), this aims to be native and light, to be as unobtrusive as possible, but easily accessible.
29-
It lives in the tray, with the minutes left as countdown. A flyout on click shows more information. Every 20 minutes, a pop up appears in the centre of your screen. You can choose to delay, or press enter to start the countdown (optional: accompanied by white noise). Every 3rd pop up is for 2 minutes.
3040

31-
### Keyboard (while the popup is focused)
41+
It lives in the tray, with the minutes left as countdown.
42+
A flyout on click shows more information. Every 20 minutes, a pop up appears in the centre of your screen.
43+
You can choose to delay, or press enter to start the countdown (optional: accompanied by white noise). Every 3rd pop up is for 2 minutes.
44+
45+
### Keyboard
3246

3347
- `Enter` — start the break timer
3448
- `Esc` — snooze 5 minutes
@@ -38,8 +52,9 @@ It lives in the tray, with the minutes left as countdown. A flyout on click show
3852

3953
Snooze 5 / 15 / 30 minutes · Mute or Unmute sounds
4054

41-
### Sounds (mutable)
55+
### Sounds
4256

57+
Mutable through the context menue (right click tray icon)
4358
- Soft 1318 Hz pre-ping 5 seconds before
4459
- Warm 3-note chime when the popup appears
4560
- Brown-noise water ambient during the break
@@ -61,15 +76,14 @@ dotnet build
6176
dotnet run
6277
```
6378

64-
The app starts straight into the system tray.
6579

6680
## Build a portable single-file exe
6781

6882
```pwsh
6983
dotnet publish -c Release -r win-x64 -o publish
7084
```
7185

72-
Output: `publish\Twenti.exe`. The Windows App SDK runtime is bundled.
86+
Output: `publish\Twenti.exe`. Windows App SDK runtime is bundled.
7387

7488
## Build the installer
7589

@@ -78,4 +92,4 @@ dotnet publish -c Release -r win-x64 -o publish
7892
& "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" installer\Twenti.iss
7993
```
8094

81-
Output: `installer\Output\Twenti-Setup.exe`. Installs as user or system wide. Adds a Start Menu entry, optionally runs at login.
95+
Output: `installer\Output\Twenti-Setup.exe`. Installs as user or system wide, adds a Start Menu entry, optionally runs at login.

Services/Win32Helper.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,27 @@ public static void HideFromAltTab(IntPtr hwnd)
9191
SetWindowLongPtr(hwnd, GWL_EXSTYLE, new IntPtr(ex | WS_EX_TOOLWINDOW));
9292
}
9393

94+
[DllImport("user32.dll")]
95+
public static extern IntPtr GetForegroundWindow();
96+
97+
[DllImport("user32.dll")]
98+
public static extern bool SetForegroundWindow(IntPtr hwnd);
99+
100+
// ── Global foreground-change hook ──────────────────────────────────────
101+
public delegate void WinEventCallback(IntPtr hWinEventHook, uint eventType,
102+
IntPtr hwnd, int idObject, int idChild, uint dwEventThread, uint dwmsEventTime);
103+
104+
public const uint EVENT_SYSTEM_FOREGROUND = 0x0003;
105+
public const uint WINEVENT_OUTOFCONTEXT = 0x0000;
106+
107+
[DllImport("user32.dll")]
108+
public static extern IntPtr SetWinEventHook(uint eventMin, uint eventMax,
109+
IntPtr hmodWinEventProc, WinEventCallback lpfnWinEventProc,
110+
uint idProcess, uint idThread, uint dwFlags);
111+
112+
[DllImport("user32.dll")]
113+
public static extern bool UnhookWinEvent(IntPtr hWinEventHook);
114+
94115
[StructLayout(LayoutKind.Sequential)]
95116
private struct POINT { public int X; public int Y; }
96117

Views/TrayFlyout.xaml.cs

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.ComponentModel;
33
using Microsoft.UI;
4+
using Microsoft.UI.Dispatching;
45
using Microsoft.UI.Windowing;
56
using Microsoft.UI.Xaml;
67
using Microsoft.UI.Xaml.Media;
@@ -14,9 +15,20 @@ public sealed partial class TrayFlyout : Window
1415
{
1516
private readonly BreakStateMachine _sm;
1617
private AppWindow? _appWindow;
18+
private IntPtr _hwnd;
1719
private double _scale = 1.0;
1820
private bool _isVisible;
1921

22+
// Foreground-change hook. We keep a strong reference to the delegate so the
23+
// GC doesn't reclaim it while the OS still has the function pointer.
24+
private IntPtr _hook = IntPtr.Zero;
25+
private Win32Helper.WinEventCallback? _hookProc;
26+
27+
// Brief grace period right after Show() — Windows may not have made us the
28+
// foreground window yet, and we don't want to immediately self-dismiss.
29+
private DateTime _shownAt;
30+
private static readonly TimeSpan GracePeriod = TimeSpan.FromMilliseconds(250);
31+
2032
public TrayFlyout()
2133
{
2234
InitializeComponent();
@@ -28,53 +40,44 @@ public TrayFlyout()
2840
ConfigureChromeOnce();
2941

3042
_sm.PropertyChanged += OnStateChanged;
31-
Activated += OnActivated;
3243

3344
UpdateUi();
34-
35-
// Stay hidden until first ShowFlyout(). Avoids the window flashing on app start.
3645
_appWindow?.Hide();
3746
}
3847

3948
private void ConfigureChromeOnce()
4049
{
41-
var hwnd = WindowNative.GetWindowHandle(this);
42-
var id = Win32Interop.GetWindowIdFromWindow(hwnd);
50+
_hwnd = WindowNative.GetWindowHandle(this);
51+
var id = Win32Interop.GetWindowIdFromWindow(_hwnd);
4352
_appWindow = AppWindow.GetFromWindowId(id);
4453

4554
if (_appWindow.Presenter is OverlappedPresenter p)
4655
{
4756
p.IsMaximizable = false;
4857
p.IsMinimizable = false;
4958
p.IsResizable = false;
50-
// IsAlwaysOnTop must be true so the flyout always lands above the
51-
// window the user clicked from. Click-away is handled via the
52-
// Activated event — it still fires for topmost windows.
59+
// Topmost so it draws above the user's app windows when shown.
60+
// The foreground-change hook below handles dismissal.
5361
p.IsAlwaysOnTop = true;
5462
p.SetBorderAndTitleBar(false, false);
5563
}
5664

5765
_appWindow.IsShownInSwitchers = false;
58-
Win32Helper.HideFromAltTab(hwnd);
59-
Win32Helper.MakeBorderless(hwnd);
60-
Win32Helper.ForceImmersiveDark(hwnd);
61-
Win32Helper.RoundCorners(hwnd);
62-
Win32Helper.RemoveBorder(hwnd);
66+
Win32Helper.HideFromAltTab(_hwnd);
67+
Win32Helper.MakeBorderless(_hwnd);
68+
Win32Helper.ForceImmersiveDark(_hwnd);
69+
Win32Helper.RoundCorners(_hwnd);
70+
Win32Helper.RemoveBorder(_hwnd);
6371

64-
_scale = Win32Helper.GetDpiScale(hwnd);
72+
_scale = Win32Helper.GetDpiScale(_hwnd);
6573
}
6674

67-
/// <summary>
68-
/// Repositions and shows the flyout. Cheap because the Window already exists —
69-
/// we just move the AppWindow and call Show().
70-
/// </summary>
7175
public void ShowAt()
7276
{
7377
if (_appWindow is null) return;
7478

7579
UpdateUi();
7680

77-
// Recalculate target monitor every time, since the cursor may have moved.
7881
const int logicalWidth = 280;
7982
const int logicalHeight = 165;
8083
int width = (int)Math.Round(logicalWidth * _scale);
@@ -87,23 +90,61 @@ public void ShowAt()
8790

8891
_appWindow.MoveAndResize(new RectInt32(x, y, width, height));
8992
_appWindow.Show(activateWindow: true);
90-
_isVisible = true;
93+
94+
// Drag ourselves to the foreground — necessary because the click came
95+
// from the tray (Explorer.exe), so we don't automatically get focus.
96+
Win32Helper.SetForegroundWindow(_hwnd);
9197
Activate();
98+
99+
_shownAt = DateTime.UtcNow;
100+
_isVisible = true;
101+
102+
InstallForegroundHook();
92103
}
93104

94105
public void HideQuiet()
95106
{
96107
if (!_isVisible || _appWindow is null) return;
97108
_isVisible = false;
109+
UninstallForegroundHook();
98110
_appWindow.Hide();
99111
}
100112

101-
private void OnActivated(object sender, Microsoft.UI.Xaml.WindowActivatedEventArgs args)
113+
private void InstallForegroundHook()
102114
{
103-
if (args.WindowActivationState == WindowActivationState.Deactivated)
115+
if (_hook != IntPtr.Zero) return;
116+
_hookProc = OnForegroundChanged;
117+
_hook = Win32Helper.SetWinEventHook(
118+
Win32Helper.EVENT_SYSTEM_FOREGROUND,
119+
Win32Helper.EVENT_SYSTEM_FOREGROUND,
120+
IntPtr.Zero,
121+
_hookProc,
122+
idProcess: 0,
123+
idThread: 0,
124+
dwFlags: Win32Helper.WINEVENT_OUTOFCONTEXT);
125+
}
126+
127+
private void UninstallForegroundHook()
128+
{
129+
if (_hook == IntPtr.Zero) return;
130+
Win32Helper.UnhookWinEvent(_hook);
131+
_hook = IntPtr.Zero;
132+
_hookProc = null;
133+
}
134+
135+
private void OnForegroundChanged(IntPtr hWinEventHook, uint eventType,
136+
IntPtr hwnd, int idObject, int idChild, uint dwEventThread, uint dwmsEventTime)
137+
{
138+
// Hook fires on a non-UI thread — bounce back to the dispatcher.
139+
App.Current.UIQueue.TryEnqueue(() =>
104140
{
105-
HideQuiet();
106-
}
141+
if (!_isVisible) return;
142+
// Ignore foreground changes during the grace period after show, so
143+
// we don't dismiss before SetForegroundWindow has taken effect.
144+
if (DateTime.UtcNow - _shownAt < GracePeriod) return;
145+
// Hide whenever any window other than ourselves becomes foreground.
146+
if (hwnd != _hwnd) HideQuiet();
147+
});
107148
}
108149

109150
private void OnStateChanged(object? sender, PropertyChangedEventArgs e)

0 commit comments

Comments
 (0)