# systray.tcl -- # This file defines the 'tk systray' command for icon display and manipulation # in the system tray on X11, Windows, and macOS, and the 'tk sysnotify' command # for system alerts on each platform. It implements an abstraction layer that # presents a consistent API across the three platforms. # Copyright © 2020 Kevin Walzer/WordTech Communications LLC. # Copyright © 2020 Eric Boudaillier. # Copyright © 2020 Francois Vogel. # # See the file "license.terms" for information on usage and redistribution # of this file, and for a DISCLAIMER OF ALL WARRANTIES. # Pure-Tcl system tooltip window for use with system tray icon if native # implementation not available. namespace eval ::tk::systray { variable _created 0 variable _options {-image "" -text "" -button1 "" -button3 ""} variable _current {} variable _ico proc _balloon {w help} { bind $w "after 100 [list [namespace current]::_balloon_show %W [list $help] cursor]" bind $w "destroy %W._balloon" } proc _balloon_show {w msg i} { if {![winfo exists $w]} { return } # Use string match to allow that the help will be shown when # the pointer is in any child of the desired widget if {([winfo class $w] ne "Menu") && ![string match $w* [eval [list winfo containing] \ [winfo pointerxy $w]]]} { return } set top $w._balloon ::destroy $top toplevel $top -bg black -bd 1 wm overrideredirect $top 1 wm state $top withdrawn if {[tk windowingsystem] eq "aqua"} { ::tk::unsupported::MacWindowStyle style $top help none } pack [message $top._txt -aspect 10000 -text $msg] update idletasks set screenw [winfo screenwidth $w] set screenh [winfo screenheight $w] set reqw [winfo reqwidth $top] set reqh [winfo reqheight $top] # When adjusting for being on the screen boundary, check that we are # near the "edge" already, as Tk handles multiple monitors oddly if {$i eq "cursor"} { set y [expr {[winfo pointery $w]+20}] if {($y < $screenh) && ($y+$reqh) > $screenh} { set y [expr {[winfo pointery $w]-$reqh-5}] } } elseif {$i ne ""} { set y [expr {[winfo rooty $w]+[winfo vrooty $w]+[$w yposition $i]+25}] if {($y < $screenh) && ($y+$reqh) > $screenh} { # show above if we would be offscreen set y [expr {[winfo rooty $w]+[$w yposition $i]-$reqh-5}] } } else { set y [expr {[winfo rooty $w]+[winfo vrooty $w]+[winfo height $w]+5}] if {($y < $screenh) && ($y+$reqh) > $screenh} { # show above if we would be offscreen set y [expr {[winfo rooty $w]-$reqh-5}] } } if {$i eq "cursor"} { set x [winfo pointerx $w] } else { set x [expr {[winfo rootx $w]+[winfo vrootx $w]+ ([winfo width $w]-$reqw)/2}] } # only readjust when we would appear right on the screen edge if {$x<0 && ($x+$reqw)>0} { set x 0 } elseif {($x < $screenw) && ($x+$reqw) > $screenw} { set x [expr {$screenw-$reqw}] } if {[tk windowingsystem] eq "aqua"} { set focus [focus] } wm geometry $top +$x+$y wm deiconify $top raise $top if {[tk windowingsystem] eq "aqua" && $focus ne ""} { # Aqua's help window steals focus on display after idle [list focus -force $focus] } } proc _win_callback {msg} { variable _current # The API at the Tk level does not feature bindings to double clicks. Whatever # the speed the user clicks with, he expects the single click binding to fire. # Therefore we need to bind to both WM_*BUTTONDOWN and to WM_*BUTTONDBLCLK. switch -exact -- $msg { WM_LBUTTONDOWN - WM_LBUTTONDBLCLK { uplevel #0 [dict get $_current -button1] } WM_RBUTTONDOWN - WM_RBUTTONDBLCLK { uplevel #0 [dict get $_current -button3] } } } namespace export create configure destroy exists namespace ensemble create } # Pure-Tcl system notification window for use if native implementation not available. # This is supposed to happen only on X11 when libnotify is not present. namespace eval ::tk::sysnotify:: { # These defaults mimic the default behaviour of gnome and xfce notifications. # These are hardcoded defaults. variable defaults { padX 3 padY 3 background gray15 foreground white delay 10000 alpha 0.85 } # These options are meant to be "public". The user could tinker with # these values to adjust the system notification appearance/behaviour. option add *Sysnotify.padX [dict get $defaults padX] option add *Sysnotify.padY [dict get $defaults padY] option add *Sysnotify.background [dict get $defaults background] option add *Sysnotify.foreground [dict get $defaults foreground] option add *Sysnotify.delay [dict get $defaults delay] option add *Sysnotify.alpha [dict get $defaults alpha] proc _notifywindow {title msg} { variable defaults # cleanup any previous notify window and create a new one set w ._notify _notifyDestroy $w toplevel $w -class Sysnotify # read the option database to check out whether the user has set # some options; fall back to our hardcoded defaults otherwise dict for {option value} [dict remove $defaults alpha] { set $option [option get $w $option ""] if {[set $option] eq ""} { set $option $value } } set xpos [tk::ScaleNum 16] set ypos [tk::ScaleNum 48] # position from the "ne" corner wm geometry $w -$xpos+$ypos wm overrideredirect $w true # internal options option add *Sysnotify.Label.anchor w option add *Sysnotify.Label.justify left option add *Sysnotify.Label.wrapLength [expr {[winfo screenwidth .] / 4}] foreach option {padX padY foreground background} { option add *Sysnotify.Label.$option [set $option] } set icon ::tk::icons::information set width [expr {[image width $icon] + 2 * $padX}] set height [expr {[image height $icon] + 2 * $padY}] label $w.icon -image $icon -width $width -height $height -anchor c label $w.title -text $title -font TkHeadingFont label $w.message -text [_filterMarkup $msg] -font TkTooltipFont grid $w.icon $w.title -sticky news grid ^ $w.message -sticky news bind Sysnotify [namespace code { # set the wm attribute here; it is ignored if set # before the window is mapped wm attributes %W -alpha 0.0 if {[wm attributes %W -alpha] == 0.0} { _fadeIn %W } }] bind Sysnotify [namespace code {_onEnter %W}] bind Sysnotify [namespace code {_onLeave %W}] bind $w [namespace code [list _notifyDestroy $w]] after $delay [namespace code [list _fadeOut $w]] return } # Fade the window into view. proc _fadeIn {w} { variable defaults if {![winfo exists $w]} {return} if {[set alpha [option get $w alpha ""]] eq ""} { set alpha [dict get $defaults alpha] } raise $w set before [wm attributes $w -alpha] set new [expr { min($alpha, $before + 0.10) }] wm attributes $w -alpha $new set after [wm attributes $w -alpha] if {($before == 1.0) || ($before == $after)} { # not supported or we're done return } after 40 [namespace code [list _fadeIn $w]] } # Fade out and destroy window. proc _fadeOut {w} { if {![winfo exists $w]} {return} set before [wm attributes $w -alpha] set new [expr { $before - 0.02 }] wm attributes $w -alpha $new set after [wm attributes $w -alpha] if {($after == 1.0) || ($before == $after)} { _notifyDestroy $w return } after 40 [namespace code [list _fadeOut $w]] } proc _notifyDestroy {w} { # cancel any pending fade in or fade out _cancelFading $w destroy $w } proc _onEnter {w} { wm attributes $w -alpha 1.0 _cancelFading $w } proc _onLeave {w} { variable defaults if {[set alpha [option get $w alpha ""]] eq ""} { set alpha [dict get $defaults alpha] } if {[set delay [option get $w delay ""]] eq ""} { set delay [dict get $defaults delay] } wm attributes $w -alpha $alpha after $delay [namespace code [list _fadeOut $w]] } proc _cancelFading {w} { after cancel [namespace code [list _fadeOut $w]] after cancel [namespace code [list _fadeIn $w]] } # The Desktop Notifications Specification allow for some markup # in the message to display. It also specifies # "Notification servers that do not support these tags should # filter them out" # See https://specifications.freedesktop.org/notification-spec/latest/ar01s04.html # We don't event try to render those properly proc _filterMarkup {txt} { # remove fixed tags set maplist { "" "" "" "" "" "" ""} set txt [string map $maplist $txt] # remove tags leaving (possible) alt text set txt [regsub -- {} $txt {\2}] # remove variable tag set txt [regsub -- {]*>} $txt {}] return $txt } } # tk systray -- # This procedure creates an icon display in the platform-specific system tray. # # Subcommands: # # create - create systray icon. # Arguments: # -image - Tk image to display. # -text - string to display in tooltip over image. # -button1 - Tcl proc to invoke on event. # -button3 - Tcl proc to invoke on event. # # configure - change one of the systray properties. # Arguments (Any or all can be called): # -image - Tk image to update. # -text - string to update. # -button1 - Tcl proc to change for event. # -button3 - Tcl proc to change for event. # # destroy - destroy systray icon. # Arguments: # none. proc ::tk::systray::create {args} { variable _created variable _options variable _current variable _ico if {$_created} { return -code error -errorcode {TK SYSTRAY CREATE} "only one system tray icon supported per interpeter" } _check_options $args 0 if {![dict exists $args -image]} { return -code error -errorcode {TK SYSTRAY CREATE} "missing required option \"-image\"" } set values [dict merge $_options $args] try { switch -- [tk windowingsystem] { "win32" { set _ico [_systray add -image [dict get $values -image] \ -text [dict get $values -text] \ -callback [list ::tk::systray::_win_callback %m]] } "x11" { _systray ._tray -image [dict get $values -image] -visible true _balloon ._tray [dict get $values -text] bind ._tray [dict get $values -button1] bind ._tray [dict get $values -button3] } "aqua" { _systray create [dict get $values -image] [dict get $values -text] \ [dict get $values -button1] [dict get $values -button3] } } } on ok {} { set _current $values set _created 1 return } on error {msg opts} { return -code error -errorcode [dict get $opts -errorcode] $msg } } # Modify the systray icon. proc ::tk::systray::configure {args} { variable _created variable _options variable _current variable _ico if {!$_created} { return -code error -errorcode {TK SYSTRAY CREATE} "systray not created" } _check_options $args 1 if {[llength $args] == 0} { return $_current } elseif {[llength $args] == 1} { return [dict get $_current [lindex $args 0]] } set values [dict merge $_current $args] try { switch -- [tk windowingsystem] { "win32" { if {[dict exists $args -image]} { _systray modify $_ico -image [dict get $args -image] } if {[dict exists $args -text]} { _systray modify $_ico -text [dict get $args -text] } } "x11" { if {[dict exists $args -image]} { ._tray configure -image [dict get $args -image] } if {[dict exists $args -text]} { _balloon ._tray [dict get $args -text] } if {[dict exists $args -button1]} { bind ._tray [dict get $args -button1] } if {[dict exists $args -button3]} { bind ._tray [dict get $args -button3] } } "aqua" { foreach {key opt} {image -image text \ -text b1_callback -button1 b3_callback -button3} { if {[dict exists $args $opt]} { _systray modify $key [dict get $args $opt] } } } } } on ok {} { set _current $values return } on error {msg opts} { return -code error -errorcode [dict get $opts -errorcode] $msg } } # Remove the systray icon. proc ::tk::systray::destroy {} { variable _created variable _current variable _ico if {!$_created} { return -code error -errorcode {TK SYSTRAY DESTROY} "systray not created" } switch -- [tk windowingsystem] { "win32" { _systray delete $_ico set _ico "" } "x11" { ::destroy ._tray } "aqua" { _systray destroy } } set _created 0 set _current {} return } # Check systray icon existence. proc tk::systray::exists {} { variable _created return $_created } # Check systray options proc ::tk::systray::_check_options {argsList singleOk} { variable _options set len [llength $argsList] while {[llength $argsList] > 0} { set opt [lindex $argsList 0] if {![dict exists $_options $opt]} { tailcall return -code error -errorcode {TK SYSTRAY OPTION} \ "unknown option \"$opt\": must be -image, -text, -button1 or -button3" } if {[llength $argsList] == 1 && !($len == 1 && $singleOk)} { tailcall return -code error -errorcode {TK SYSTRAY OPTION} \ "missing value for option \"$opt\"" } set argsList [lrange $argsList 2 end] } } # tk sysnotify -- # This procedure implements a platform-specific system notification alert. # # Arguments: # title - main text of alert. # message - body text of alert. proc ::tk::sysnotify::sysnotify {title message} { switch -- [tk windowingsystem] { "win32" { if {!$::tk::systray::_created} { error "must create a system tray icon with the \"tk systray\" command first" } _sysnotify notify $::tk::systray::_ico $title $message } "x11" { if {[info commands ::tk::sysnotify::_sysnotify] eq ""} { _notifywindow $title $message } else { _sysnotify $title $message } } "aqua" { _sysnotify $title $message } } return } #Add these commands to the tk command ensemble: tk systray, tk sysnotify #Thanks to Christian Gollwitzer for the guidance here namespace ensemble configure tk -map \ [dict merge [namespace ensemble configure tk -map] \ {systray ::tk::systray sysnotify ::tk::sysnotify::sysnotify}]