Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,21 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y xvfb xauth libgl1-mesa-dri libglx-mesa0 \
libxcursor1 libxfixes3 libxtst6
libxcursor1 libxfixes3 libxtst6 libdbus-1-3 dbus-daemon xfonts-base
nim check tests/test_x11.nim
nim c tests/test_x11.nim
xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 ./tests/test_x11
xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \
MESA_GL_VERSION_OVERRIDE=3.3 ./tests/test_x11 --unsupported-version

- name: Test Linux tray backends
if: matrix.os == 'ubuntu-latest'
run: |
nim c tests/test_tray.nim
./tests/test_tray
nim c -d:release tests/test_tray.nim
./tests/test_tray

# Build native examples.
- run: nim c examples/basic.nim
- run: nim c examples/basic_boxy.nim
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Features:
* Easy polling of keyboard state via `buttonDown[Button]` and more
* IME support (for Chinese, Japanese, etc. text input)
* System clipboard (copy and paste) support
* Show a system tray icon and menu (Windows only)
* Show a system tray icon and menu (Windows and Linux)
* Non-blocking HTTP requests and WebSockets

## Basic Example
Expand All @@ -52,6 +52,18 @@ while not window.closeRequested:

[Check out more examples here.](https://github.com/treeform/windy/tree/master/examples)

## Tray icons

`showTrayIcon(image, tooltip, onClick, menu)` shows one icon;
calling it again replaces the icon and menu. Call `pollEvents()` to process
interaction, and `hideTrayIcon()` to remove it. See [examples/tray.nim](examples/tray.nim).

Linux selects an available StatusNotifierItem host, or an XEmbed tray when
SNI is unavailable. It waits if neither exists and recovers when the tray host
restarts. SNI uses the optional `libdbus-1.so.3` runtime library. XEmbed uses
Xlib with a basic popup menu; SNI menus follow the desktop's presentation.
Tray-only Linux applications do not need to create a window or OpenGL context.

## CPU Pixels

On Windows and X11 Linux, compile with `-d:useCpu` to create a plain window without an
Expand Down
3 changes: 1 addition & 2 deletions examples/tray.nim
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import pixie, windy

when defined(windows):
# Tray API only currently supported on Windows
when defined(windows) or (defined(linux) and not defined(emscripten)):

let window = newWindow("Windy Tray Icon", ivec2(1280, 800))
window.makeContextCurrent()
Expand Down
12 changes: 12 additions & 0 deletions src/windy/common.nim
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ type
DecoratedResizable, Decorated, Undecorated, Transparent

Callback* = proc()
TrayMenuEntryKind* = enum
TrayMenuOption, TrayMenuSeparator

TrayMenuEntry* = object
## A tray menu option or separator. Option labels are literal UTF-8 text.
case kind*: TrayMenuEntryKind
of TrayMenuOption:
text*: string
onClick*: Callback
of TrayMenuSeparator:
discard

ButtonCallback* = proc(button: Button)
RuneCallback* = proc(rune: Rune)
TextInputCallback* = proc(text: string)
Expand Down
4 changes: 3 additions & 1 deletion src/windy/platforms/linux/platform.nim
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import ../../common, ./backends
import ../../common, ./backends, ./tray
export backends
export tray except pollTrayEvents

when not HasDirect:
import ./x11
Expand Down Expand Up @@ -61,6 +62,7 @@ else:
if selected == X11Backend:
when HasX11: x11.pollEvents()
else:
pollTrayEvents()
direct.pollEvents()
pollHttp()

Expand Down
105 changes: 105 additions & 0 deletions src/windy/platforms/linux/tray.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import
std/unicode,
pixie,
../../common,
./backends,
./tray/[state, sni]

when HasX11:
import ./tray/xembed

type
TrayBackend = enum
noTray, sniTray, xembedTray

var
requested: TrayState
busTray: SniTray
backend: TrayBackend
revision: uint32
nextMenuId = 1.int32

when HasX11:
var plugTray: XembedTray

proc hideTrayIcon*() =
## Removes the Linux tray icon and cancels pending registration. Safe to repeat.
requested = nil
backend = noTray
busTray.close()
when HasX11:
plugTray.close()
plugTray = nil
busTray = nil

proc showTrayIcon*(icon: Image, tooltip: string, onClick: Callback,
menu: seq[TrayMenuEntry] = @[]) =
## Shows or replaces one tray icon. Call pollEvents to process tray interaction.
## Waits for a tray host when none is present; prefers SNI, then XEmbed.
## Labels are literal UTF-8. SNI hosts determine how activation is presented.
if icon == nil or icon.width <= 0 or icon.height <= 0:
raise newException(WindyError, "A tray icon must contain pixels")
if '\0' in tooltip or tooltip.validateUtf8() != -1:
raise newException(WindyError, "A tray tooltip must be UTF-8 without null bytes")
for entry in menu:
if entry.kind == TrayMenuOption and
('\0' in entry.text or entry.text.validateUtf8() != -1):
raise newException(WindyError, "A tray menu label must be UTF-8 without null bytes")
if menu.len > (high(int32) - nextMenuId).int:
raise newException(WindyError, "Tray menu IDs exhausted")
inc revision
let state = TrayState(icon: icon.copy(), tooltip: tooltip,
onClick: onClick, revision: revision, firstMenuId: nextMenuId)
nextMenuId += menu.len.int32
# Own the sequence so later application edits cannot change a published menu.
for entry in menu: state.menu.add entry
if requested == nil:
requested = state
try:
busTray = newSniTray(state)
when HasX11:
plugTray = newXembedTray(state)
except:
hideTrayIcon()
raise
else:
requested = state
busTray.update(state)
when HasX11:
plugTray.update(state)

proc pollTrayEvents*() =
## Pumps both host monitors and dispatches callbacks for the current snapshot.
if requested == nil: return
let snapshot = requested
var callbacks = busTray.poll()
when HasX11:
callbacks.add plugTray.poll()
if backend == sniTray and not busTray.hostAvailable and not busTray.selecting:
# Closing the connection unregisters even if the watcher itself still exists.
let failedOwner = busTray.failedOwner
busTray.close()
busTray = newSniTray(requested, failedOwner)
backend = noTray
when HasX11:
if backend == xembedTray and not plugTray.available:
plugTray.setActive(false)
backend = noTray
if backend == noTray:
if busTray.hostAvailable:
backend = sniTray
busTray.setActive(true)
elif not busTray.selecting:
when HasX11:
if plugTray.available:
backend = xembedTray
plugTray.setActive(true)
elif backend == sniTray:
busTray.setActive(true)
when HasX11:
if backend == xembedTray:
# Redock if the manager retained ownership but recreated the plug.
plugTray.setActive(true)
for callback in callbacks:
if requested != snapshot: break
callback()
Loading
Loading