juanpablolezcano/eye-reminder

C#

0

15 commits

updated Sep 24, 2026

See the code

See what people are saying

SourceMessageScoreDate

I made a Windows app to follow the 20-20-20 rule (r/SideProject)

Been struggling with eye strain for a while until I learned about this (not so) silly rule. It definitely helped, the hard part was actually sticking to it. So I wrote a little Windows app that pops up on screen every 20 minutes and reminds me to look away. It's pretty configurable, doesn't steal…

1

Sep 24, 2026

README

EyeReminder

A desktop overlay for the 20/20/20 rule: every 20 minutes, look at something about 20 feet (6 m) away for 20 seconds.

image

The card floats above everything else and never takes focus, so it cannot interrupt what you are typing. Clicking it dismisses it, which is one gesture away when a reminder lands in the middle of a call.

The card

Where you clickWhat happens
Anywhere on the carddismisses it
The Xdismisses it
The gearopens the settings window

How the overlay works

The window is created with these Win32 extended styles (Win32.cs):

FlagEffect
WS_EX_NOACTIVATEnever takes focus, not even when clicked
WS_EX_TOOLWINDOWhidden from Alt+Tab and the taskbar
WS_EX_LAYEREDreal transparency and rounded corners

WS_EX_NOACTIVATE is what matters: the card can be clicked without the window you were working in losing the foreground. Only the card's own rectangle takes the mouse; the rest of the desktop is untouched.

It is then positioned with SetWindowPos + SWP_NOACTIVATE in physical pixels, which keeps it correct on multi-monitor setups with mixed DPI.

Dependencies

None outside Microsoft. Everything comes from .NET 10 and Windows itself:

WhatWhere from
WPF (UseWPF).NET 10 Windows Desktop
WinForms (UseWindowsForms).NET 10 Windows Desktop; only for NotifyIcon and Screen
System.Text.Json.NET runtime
System.Media.SoundPlayer.NET runtime
System.Drawing.NET runtime; draws the tray icon at runtime
Microsoft.Win32.Registry.NET runtime; "start with Windows"
user32.dll, dwmapi.dll, shell32.dllWindows, through P/Invoke

dotnet list package reports no package the project asked for; the only entry is Microsoft.NET.ILLink.Tasks, which the SDK adds by itself and is a build-time tool, not code that ends up in the executable.

Building and running

Requires the .NET 10 SDK on Windows.

dotnet publish -c Release -o publish
.\publish\EyeReminder.exe

It opens no window at startup: it lives as a system tray icon next to the clock, and shows a short "running in the background" card so you know it started.

ActionResult
Double-click the tray iconopens the settings window
Right-clickmenu
EyeReminder.exe --settingsopens settings directly

Tray menu:

  • Next break in mm:ss: time remaining
  • Test now: fires the card, repeatable as often as you like
  • Restart timer: starts the interval over
  • Pause for 1 hour / Resume
  • Settings...
  • Exit

Settings

Everything is configured from the window (Settings... in the tray). Your preferences are stored in:

%APPDATA%\EyeReminder\settings.json

Not next to the .exe, so they survive rebuilds, reinstalls, and moving the app into a read-only folder. The settings.json that ships beside the executable holds the factory defaults and is only used to seed your profile on first run.

KeyDefaultWhat it does
intervalMinutes20minutes between reminders
breakSeconds20how long the card stays up
countdownSeconds33, 2, 1 heads-up before the break; 0 skips it
showStartupNoticetrue"running in the background" card at launch
pauseWhenFullscreentruehold the reminder during full screen, presentations and Focus Assist
hideFromScreenSharetruekeep the card out of screen captures and shares
pauseWhenIdletruepause while there is no keyboard or mouse input
idleMinutes5minutes of inactivity before pausing
languageautolanguage code, or auto to follow Windows
titleOverride""overrides the language's title
countdownOverride""overrides the countdown text
messageOverride""overrides the break message
themeDarkDark, Light, Warm, Minimal, or one of your own
accentColor""hex override for the theme accent, e.g. #E894B4
animationFadeFade, Slide, Scale, None
positionTopCenterTopCenter, TopLeft, TopRight, BottomLeft, BottomRight, Center
scale1.0card size (0.7 to 2.0)
allScreensfalseshow it on every monitor
opacity0.95card opacity
soundtrueplay a sound when the break starts
soundFile""sound to play; empty uses the built-in chime
soundVolume0.45volume of the built-in chime (0 to 1)
soundOnFinishtruereplay the sound when the break ends

Preview shows the card with whatever is currently in the form without saving, so you can try a style, position and size before committing.

A missing or malformed file falls back to the defaults.

Staying out of the way

Two settings under Privacy keep the card from turning up at the wrong moment.

Do not show during full screen or a presentation asks the shell what the user is doing, through SHQueryUserNotificationState. The reminder is held back while an app is full screen, a Direct3D game is running, presentation mode is on, or Focus Assist is silencing notifications, and it resumes the moment that ends.

Hide from screen captures and shares calls SetWindowDisplayAffinity with WDA_EXCLUDEFROMCAPTURE. The compositor then refuses to hand the card's pixels to any capture, so it is absent from a Discord or Teams share, from OBS, and from screenshots. You still see it on your own screen.

That needs Windows 10 2004 or newer. On anything older the call fails, a line goes into log.txt, and the card behaves normally.

Themes

The four shipped themes are Dark, Light, Warm and Minimal, and Customise... under them opens an editor seeded from whichever is selected. It has a name, the six colours of the palette, and a live preview built from the card's own shapes, so the result is visible while editing. Clicking a swatch opens a picker with hue, saturation, brightness and opacity.

The shipped themes are read only: editing one starts a copy, pre-named "Warm 2" and so on, so the originals are always there to go back to. Reset colours puts the fields back to the theme the editor was opened from, and Delete removes one of your own.

Saving writes to %APPDATA%\EyeReminder\themes.json, seeded from the shipped set so nothing is lost, and the new theme shows up as a chip straight away.

Restore defaults, at the bottom of the settings window, puts every setting back to the factory values and discards that file. It asks first, then applies and writes in one step rather than waiting for Save.

The theme reaches the tray too: the right-click menu is painted from the palette, its highlighted row uses the accent colour, and the label on that row flips between black and white by WCAG contrast so it stays readable on any accent. The tray icon is drawn in the accent as well. It is not an SVG, it is drawn with GDI+ at runtime in EyeIcon.cs, which is why it can be recoloured without shipping an asset.

The one place that cannot follow the theme is the executable's own icon, since Windows reads that from a resource compiled into the file. EyeReminder.ico holds the same eye at seven sizes, from 16 to 256 pixels, on a dark tile: the transparent tray version is right on a taskbar but nearly invisible against a folder listing at 16 pixels.

By hand

theme names an entry in Themes/themes.json. The editor writes that same format, so a theme can equally be added by editing the file:

{
  "id": "Midnight",
  "label": "Midnight",
  "background": "#F00B1020", "border": "#2E6C7BFF", "title": "#FFEAF0FF",
  "message": "#B0EAF0FF", "accent": "#FF8AA0FF", "track": "#266C7BFF"
}

Colours are #AARRGGBB, so the leading pair is opacity. Use labelKey instead of label to name a translated caption; a plain label shows as typed in every language. Any field you leave out falls back to the dark theme's value.

Startup notice

Because the app starts with no window at all, it shows the reminder card for five seconds at launch, saying it is running in the background and when the first break is due. It is not a Windows notification; it is the same card, silent, and it fades out on its own.

Turn it off with Show a notice at startup, or "showStartupNotice": false.

Launching the app while it is already running shows the same card, rather than the second copy exiting without a word. The two instances find each other through a named event, so the running one does the talking.

Animations

animation picks how the card arrives and leaves: Fade, Slide, Scale or None. Slide takes its direction from where the card sits, so one anchored at the bottom rises into view instead of dropping in from above.

Languages and text

The overlay text is not typed in by hand: it comes from the selected language, and the break length is substituted into it. Set the break to 45 seconds and the card says "45 seconds" by itself.

Bundled languages (Languages/overlay.json):

es-AR · es-419 · es-MX · es-ES · en · pt-BR · pt-PT · it · fr · de · nl · pl · ru · tr · ja · ko · zh-Hans · zh-Hant · hi · ar

With auto it follows the Windows display language, with sensible fallbacks: any es-* that is not AR, MX or ES lands on es-419; pt-* lands on Brazil unless it is pt-PT; zh-TW, zh-HK and zh-MO land on Traditional. No match falls back to English. Arabic renders with the whole card mirrored (RTL).

In English the distance reads 20 feet; everywhere else, 6 metres.

The interface follows the same setting: the tray menu and the entire settings window are translated (Languages/ui.json, 100 strings per language). Changing the language retranslates the open window in place.

The translations were not reviewed by native speakers. If any wording reads wrong, the override below is the escape hatch, and a pull request is welcome.

Where the text lives

Both files sit in Languages/ and are embedded into the assembly at build time, so a single-file build carries every language with nothing extra to ship. OverlayText.cs and Ui.cs hold only the lookup logic.

A copy dropped in %APPDATA%\EyeReminder\ wins over the embedded one, the same way themes do, so a translation can be fixed or a language added without rebuilding.

overlay.json is a flat list, one object per language:

{ "code": "it", "label": "Italiano", "title": "Riposa gli occhi",
  "countdown": "...", "message": "... {0} secondi", "background": "... {0} min" }

ui.json maps a language code to a table of keys. Two shorthands keep regional variants from being copied four times over:

"es-MX": "es-419",                                     // reuse another table as is
"pt-PT": { "$extends": "pt-BR", "btn.save": "Guardar" } // inherit, then override

To add a language, add an entry to each file using the same code. Nothing else needs touching: the settings window builds its language chips from the list. If either file fails to parse, the app still runs; the card falls back to English and the interface shows raw keys.

Custom text

Tick Write my own text to override any of the three strings. The fields are pre-filled with the language's template, {0} included, not with the already resolved text, so custom wording still tracks the configured duration.

The override is per field: change only the message and the title still comes from the language. Use {0} wherever the break length should appear:

{ "language": "it", "messageOverride": "My own text: {0} seconds" }

A stray brace breaks nothing: if the format is invalid the text is shown as typed.

Idle pause

While there is no keyboard or mouse input for idleMinutes, the countdown holds. When you come back the interval starts over rather than firing immediately: time away from the keyboard was already time away from the screen.

This uses GetLastInputInfo (Win32.cs), which measures real system input. The trade-off: watching a video without touching anything counts as idle, so you will not be reminded then. If you watch long videos, untick it.

Start with Windows

The Start with Windows checkbox writes HKCU\Software\Microsoft\Windows\CurrentVersion\Run, pointing at the current executable (StartupManager.cs). Unticking it removes the key.

If you move the folder containing the .exe, tick the box again to re-point it.

Sound

soundFile is empty by default, which means the synthesised chime. The field in the settings window shows a translated placeholder rather than a file name, so it is clear that nothing has been chosen.

It takes either a bare file name, resolved next to the executable, or a full path.

With nothing set, or when the file named cannot be found, Chime.cs synthesises a soft two-note bell (E5 to B5, exponential decay) in memory instead. No Windows system sound is ever used: those share their timbre with error alerts.

To use your own, press Browse in the settings window, or name it directly:

{ "soundFile": "C:\\Windows\\Media\\Windows Notify Calendar.wav" }

Only uncompressed PCM .wav works, since System.Media.SoundPlayer does not decode MP3. Convert with ffmpeg -i in.mp3 -acodec pcm_s16le -ar 44100 out.wav.

Free sources: Pixabay, Mixkit, Freesound.

Contributing

Changes reach main through pull requests only. See CONTRIBUTING.md; translations and themes are the easiest place to start.

Licence

MIT. See LICENSE.

Contributors

juanpablolezcano/eye-reminder

C#

0

15 commits

updated Sep 24, 2026

See the code

See what people are saying

SourceMessageScoreDate

I made a Windows app to follow the 20-20-20 rule (r/SideProject)

Been struggling with eye strain for a while until I learned about this (not so) silly rule. It definitely helped, the hard part was actually sticking to it. So I wrote a little Windows app that pops up on screen every 20 minutes and reminds me to look away. It's pretty configurable, doesn't steal…

1

Sep 24, 2026

README

EyeReminder

A desktop overlay for the 20/20/20 rule: every 20 minutes, look at something about 20 feet (6 m) away for 20 seconds.

image

The card floats above everything else and never takes focus, so it cannot interrupt what you are typing. Clicking it dismisses it, which is one gesture away when a reminder lands in the middle of a call.

The card

Where you clickWhat happens
Anywhere on the carddismisses it
The Xdismisses it
The gearopens the settings window

How the overlay works

The window is created with these Win32 extended styles (Win32.cs):

FlagEffect
WS_EX_NOACTIVATEnever takes focus, not even when clicked
WS_EX_TOOLWINDOWhidden from Alt+Tab and the taskbar
WS_EX_LAYEREDreal transparency and rounded corners

WS_EX_NOACTIVATE is what matters: the card can be clicked without the window you were working in losing the foreground. Only the card's own rectangle takes the mouse; the rest of the desktop is untouched.

It is then positioned with SetWindowPos + SWP_NOACTIVATE in physical pixels, which keeps it correct on multi-monitor setups with mixed DPI.

Dependencies

None outside Microsoft. Everything comes from .NET 10 and Windows itself:

WhatWhere from
WPF (UseWPF).NET 10 Windows Desktop
WinForms (UseWindowsForms).NET 10 Windows Desktop; only for NotifyIcon and Screen
System.Text.Json.NET runtime
System.Media.SoundPlayer.NET runtime
System.Drawing.NET runtime; draws the tray icon at runtime
Microsoft.Win32.Registry.NET runtime; "start with Windows"
user32.dll, dwmapi.dll, shell32.dllWindows, through P/Invoke

dotnet list package reports no package the project asked for; the only entry is Microsoft.NET.ILLink.Tasks, which the SDK adds by itself and is a build-time tool, not code that ends up in the executable.

Building and running

Requires the .NET 10 SDK on Windows.

dotnet publish -c Release -o publish
.\publish\EyeReminder.exe

It opens no window at startup: it lives as a system tray icon next to the clock, and shows a short "running in the background" card so you know it started.

ActionResult
Double-click the tray iconopens the settings window
Right-clickmenu
EyeReminder.exe --settingsopens settings directly

Tray menu:

  • Next break in mm:ss: time remaining
  • Test now: fires the card, repeatable as often as you like
  • Restart timer: starts the interval over
  • Pause for 1 hour / Resume
  • Settings...
  • Exit

Settings

Everything is configured from the window (Settings... in the tray). Your preferences are stored in:

%APPDATA%\EyeReminder\settings.json

Not next to the .exe, so they survive rebuilds, reinstalls, and moving the app into a read-only folder. The settings.json that ships beside the executable holds the factory defaults and is only used to seed your profile on first run.

KeyDefaultWhat it does
intervalMinutes20minutes between reminders
breakSeconds20how long the card stays up
countdownSeconds33, 2, 1 heads-up before the break; 0 skips it
showStartupNoticetrue"running in the background" card at launch
pauseWhenFullscreentruehold the reminder during full screen, presentations and Focus Assist
hideFromScreenSharetruekeep the card out of screen captures and shares
pauseWhenIdletruepause while there is no keyboard or mouse input
idleMinutes5minutes of inactivity before pausing
languageautolanguage code, or auto to follow Windows
titleOverride""overrides the language's title
countdownOverride""overrides the countdown text
messageOverride""overrides the break message
themeDarkDark, Light, Warm, Minimal, or one of your own
accentColor""hex override for the theme accent, e.g. #E894B4
animationFadeFade, Slide, Scale, None
positionTopCenterTopCenter, TopLeft, TopRight, BottomLeft, BottomRight, Center
scale1.0card size (0.7 to 2.0)
allScreensfalseshow it on every monitor
opacity0.95card opacity
soundtrueplay a sound when the break starts
soundFile""sound to play; empty uses the built-in chime
soundVolume0.45volume of the built-in chime (0 to 1)
soundOnFinishtruereplay the sound when the break ends

Preview shows the card with whatever is currently in the form without saving, so you can try a style, position and size before committing.

A missing or malformed file falls back to the defaults.

Staying out of the way

Two settings under Privacy keep the card from turning up at the wrong moment.

Do not show during full screen or a presentation asks the shell what the user is doing, through SHQueryUserNotificationState. The reminder is held back while an app is full screen, a Direct3D game is running, presentation mode is on, or Focus Assist is silencing notifications, and it resumes the moment that ends.

Hide from screen captures and shares calls SetWindowDisplayAffinity with WDA_EXCLUDEFROMCAPTURE. The compositor then refuses to hand the card's pixels to any capture, so it is absent from a Discord or Teams share, from OBS, and from screenshots. You still see it on your own screen.

That needs Windows 10 2004 or newer. On anything older the call fails, a line goes into log.txt, and the card behaves normally.

Themes

The four shipped themes are Dark, Light, Warm and Minimal, and Customise... under them opens an editor seeded from whichever is selected. It has a name, the six colours of the palette, and a live preview built from the card's own shapes, so the result is visible while editing. Clicking a swatch opens a picker with hue, saturation, brightness and opacity.

The shipped themes are read only: editing one starts a copy, pre-named "Warm 2" and so on, so the originals are always there to go back to. Reset colours puts the fields back to the theme the editor was opened from, and Delete removes one of your own.

Saving writes to %APPDATA%\EyeReminder\themes.json, seeded from the shipped set so nothing is lost, and the new theme shows up as a chip straight away.

Restore defaults, at the bottom of the settings window, puts every setting back to the factory values and discards that file. It asks first, then applies and writes in one step rather than waiting for Save.

The theme reaches the tray too: the right-click menu is painted from the palette, its highlighted row uses the accent colour, and the label on that row flips between black and white by WCAG contrast so it stays readable on any accent. The tray icon is drawn in the accent as well. It is not an SVG, it is drawn with GDI+ at runtime in EyeIcon.cs, which is why it can be recoloured without shipping an asset.

The one place that cannot follow the theme is the executable's own icon, since Windows reads that from a resource compiled into the file. EyeReminder.ico holds the same eye at seven sizes, from 16 to 256 pixels, on a dark tile: the transparent tray version is right on a taskbar but nearly invisible against a folder listing at 16 pixels.

By hand

theme names an entry in Themes/themes.json. The editor writes that same format, so a theme can equally be added by editing the file:

{
  "id": "Midnight",
  "label": "Midnight",
  "background": "#F00B1020", "border": "#2E6C7BFF", "title": "#FFEAF0FF",
  "message": "#B0EAF0FF", "accent": "#FF8AA0FF", "track": "#266C7BFF"
}

Colours are #AARRGGBB, so the leading pair is opacity. Use labelKey instead of label to name a translated caption; a plain label shows as typed in every language. Any field you leave out falls back to the dark theme's value.

Startup notice

Because the app starts with no window at all, it shows the reminder card for five seconds at launch, saying it is running in the background and when the first break is due. It is not a Windows notification; it is the same card, silent, and it fades out on its own.

Turn it off with Show a notice at startup, or "showStartupNotice": false.

Launching the app while it is already running shows the same card, rather than the second copy exiting without a word. The two instances find each other through a named event, so the running one does the talking.

Animations

animation picks how the card arrives and leaves: Fade, Slide, Scale or None. Slide takes its direction from where the card sits, so one anchored at the bottom rises into view instead of dropping in from above.

Languages and text

The overlay text is not typed in by hand: it comes from the selected language, and the break length is substituted into it. Set the break to 45 seconds and the card says "45 seconds" by itself.

Bundled languages (Languages/overlay.json):

es-AR · es-419 · es-MX · es-ES · en · pt-BR · pt-PT · it · fr · de · nl · pl · ru · tr · ja · ko · zh-Hans · zh-Hant · hi · ar

With auto it follows the Windows display language, with sensible fallbacks: any es-* that is not AR, MX or ES lands on es-419; pt-* lands on Brazil unless it is pt-PT; zh-TW, zh-HK and zh-MO land on Traditional. No match falls back to English. Arabic renders with the whole card mirrored (RTL).

In English the distance reads 20 feet; everywhere else, 6 metres.

The interface follows the same setting: the tray menu and the entire settings window are translated (Languages/ui.json, 100 strings per language). Changing the language retranslates the open window in place.

The translations were not reviewed by native speakers. If any wording reads wrong, the override below is the escape hatch, and a pull request is welcome.

Where the text lives

Both files sit in Languages/ and are embedded into the assembly at build time, so a single-file build carries every language with nothing extra to ship. OverlayText.cs and Ui.cs hold only the lookup logic.

A copy dropped in %APPDATA%\EyeReminder\ wins over the embedded one, the same way themes do, so a translation can be fixed or a language added without rebuilding.

overlay.json is a flat list, one object per language:

{ "code": "it", "label": "Italiano", "title": "Riposa gli occhi",
  "countdown": "...", "message": "... {0} secondi", "background": "... {0} min" }

ui.json maps a language code to a table of keys. Two shorthands keep regional variants from being copied four times over:

"es-MX": "es-419",                                     // reuse another table as is
"pt-PT": { "$extends": "pt-BR", "btn.save": "Guardar" } // inherit, then override

To add a language, add an entry to each file using the same code. Nothing else needs touching: the settings window builds its language chips from the list. If either file fails to parse, the app still runs; the card falls back to English and the interface shows raw keys.

Custom text

Tick Write my own text to override any of the three strings. The fields are pre-filled with the language's template, {0} included, not with the already resolved text, so custom wording still tracks the configured duration.

The override is per field: change only the message and the title still comes from the language. Use {0} wherever the break length should appear:

{ "language": "it", "messageOverride": "My own text: {0} seconds" }

A stray brace breaks nothing: if the format is invalid the text is shown as typed.

Idle pause

While there is no keyboard or mouse input for idleMinutes, the countdown holds. When you come back the interval starts over rather than firing immediately: time away from the keyboard was already time away from the screen.

This uses GetLastInputInfo (Win32.cs), which measures real system input. The trade-off: watching a video without touching anything counts as idle, so you will not be reminded then. If you watch long videos, untick it.

Start with Windows

The Start with Windows checkbox writes HKCU\Software\Microsoft\Windows\CurrentVersion\Run, pointing at the current executable (StartupManager.cs). Unticking it removes the key.

If you move the folder containing the .exe, tick the box again to re-point it.

Sound

soundFile is empty by default, which means the synthesised chime. The field in the settings window shows a translated placeholder rather than a file name, so it is clear that nothing has been chosen.

It takes either a bare file name, resolved next to the executable, or a full path.

With nothing set, or when the file named cannot be found, Chime.cs synthesises a soft two-note bell (E5 to B5, exponential decay) in memory instead. No Windows system sound is ever used: those share their timbre with error alerts.

To use your own, press Browse in the settings window, or name it directly:

{ "soundFile": "C:\\Windows\\Media\\Windows Notify Calendar.wav" }

Only uncompressed PCM .wav works, since System.Media.SoundPlayer does not decode MP3. Convert with ffmpeg -i in.mp3 -acodec pcm_s16le -ar 44100 out.wav.

Free sources: Pixabay, Mixkit, Freesound.

Contributing

Changes reach main through pull requests only. See CONTRIBUTING.md; translations and themes are the easiest place to start.

Licence

MIT. See LICENSE.

Contributors

Languages

C#

100.0%