systray.tcl 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. # systray.tcl --
  2. # This file defines the 'tk systray' command for icon display and manipulation
  3. # in the system tray on X11, Windows, and macOS, and the 'tk sysnotify' command
  4. # for system alerts on each platform. It implements an abstraction layer that
  5. # presents a consistent API across the three platforms.
  6. # Copyright © 2020 Kevin Walzer/WordTech Communications LLC.
  7. # Copyright © 2020 Eric Boudaillier.
  8. # Copyright © 2020 Francois Vogel.
  9. #
  10. # See the file "license.terms" for information on usage and redistribution
  11. # of this file, and for a DISCLAIMER OF ALL WARRANTIES.
  12. # Pure-Tcl system tooltip window for use with system tray icon if native
  13. # implementation not available.
  14. namespace eval ::tk::systray {
  15. variable _created 0
  16. variable _options {-image "" -text "" -button1 "" -button3 ""}
  17. variable _current {}
  18. variable _ico
  19. proc _balloon {w help} {
  20. bind $w <Any-Enter> "after 100 [list [namespace current]::_balloon_show %W [list $help] cursor]"
  21. bind $w <Any-Leave> "destroy %W._balloon"
  22. }
  23. proc _balloon_show {w msg i} {
  24. if {![winfo exists $w]} { return }
  25. # Use string match to allow that the help will be shown when
  26. # the pointer is in any child of the desired widget
  27. if {([winfo class $w] ne "Menu") && ![string match $w* [eval [list winfo containing] \
  28. [winfo pointerxy $w]]]} {
  29. return
  30. }
  31. set top $w._balloon
  32. ::destroy $top
  33. toplevel $top -bg black -bd 1
  34. wm overrideredirect $top 1
  35. wm state $top withdrawn
  36. if {[tk windowingsystem] eq "aqua"} {
  37. ::tk::unsupported::MacWindowStyle style $top help none
  38. }
  39. pack [message $top._txt -aspect 10000 -text $msg]
  40. update idletasks
  41. set screenw [winfo screenwidth $w]
  42. set screenh [winfo screenheight $w]
  43. set reqw [winfo reqwidth $top]
  44. set reqh [winfo reqheight $top]
  45. # When adjusting for being on the screen boundary, check that we are
  46. # near the "edge" already, as Tk handles multiple monitors oddly
  47. if {$i eq "cursor"} {
  48. set y [expr {[winfo pointery $w]+20}]
  49. if {($y < $screenh) && ($y+$reqh) > $screenh} {
  50. set y [expr {[winfo pointery $w]-$reqh-5}]
  51. }
  52. } elseif {$i ne ""} {
  53. set y [expr {[winfo rooty $w]+[winfo vrooty $w]+[$w yposition $i]+25}]
  54. if {($y < $screenh) && ($y+$reqh) > $screenh} {
  55. # show above if we would be offscreen
  56. set y [expr {[winfo rooty $w]+[$w yposition $i]-$reqh-5}]
  57. }
  58. } else {
  59. set y [expr {[winfo rooty $w]+[winfo vrooty $w]+[winfo height $w]+5}]
  60. if {($y < $screenh) && ($y+$reqh) > $screenh} {
  61. # show above if we would be offscreen
  62. set y [expr {[winfo rooty $w]-$reqh-5}]
  63. }
  64. }
  65. if {$i eq "cursor"} {
  66. set x [winfo pointerx $w]
  67. } else {
  68. set x [expr {[winfo rootx $w]+[winfo vrootx $w]+ ([winfo width $w]-$reqw)/2}]
  69. }
  70. # only readjust when we would appear right on the screen edge
  71. if {$x<0 && ($x+$reqw)>0} {
  72. set x 0
  73. } elseif {($x < $screenw) && ($x+$reqw) > $screenw} {
  74. set x [expr {$screenw-$reqw}]
  75. }
  76. if {[tk windowingsystem] eq "aqua"} {
  77. set focus [focus]
  78. }
  79. wm geometry $top +$x+$y
  80. wm deiconify $top
  81. raise $top
  82. if {[tk windowingsystem] eq "aqua" && $focus ne ""} {
  83. # Aqua's help window steals focus on display
  84. after idle [list focus -force $focus]
  85. }
  86. }
  87. proc _win_callback {msg} {
  88. variable _current
  89. # The API at the Tk level does not feature bindings to double clicks. Whatever
  90. # the speed the user clicks with, he expects the single click binding to fire.
  91. # Therefore we need to bind to both WM_*BUTTONDOWN and to WM_*BUTTONDBLCLK.
  92. switch -exact -- $msg {
  93. WM_LBUTTONDOWN - WM_LBUTTONDBLCLK {
  94. uplevel #0 [dict get $_current -button1]
  95. }
  96. WM_RBUTTONDOWN - WM_RBUTTONDBLCLK {
  97. uplevel #0 [dict get $_current -button3]
  98. }
  99. }
  100. }
  101. namespace export create configure destroy exists
  102. namespace ensemble create
  103. }
  104. # Pure-Tcl system notification window for use if native implementation not available.
  105. # This is supposed to happen only on X11 when libnotify is not present.
  106. namespace eval ::tk::sysnotify:: {
  107. # These defaults mimic the default behaviour of gnome and xfce notifications.
  108. # These are hardcoded defaults.
  109. variable defaults {
  110. padX 3
  111. padY 3
  112. background gray15
  113. foreground white
  114. delay 10000
  115. alpha 0.85
  116. }
  117. # These options are meant to be "public". The user could tinker with
  118. # these values to adjust the system notification appearance/behaviour.
  119. option add *Sysnotify.padX [dict get $defaults padX]
  120. option add *Sysnotify.padY [dict get $defaults padY]
  121. option add *Sysnotify.background [dict get $defaults background]
  122. option add *Sysnotify.foreground [dict get $defaults foreground]
  123. option add *Sysnotify.delay [dict get $defaults delay]
  124. option add *Sysnotify.alpha [dict get $defaults alpha]
  125. proc _notifywindow {title msg} {
  126. variable defaults
  127. # cleanup any previous notify window and create a new one
  128. set w ._notify
  129. _notifyDestroy $w
  130. toplevel $w -class Sysnotify
  131. # read the option database to check out whether the user has set
  132. # some options; fall back to our hardcoded defaults otherwise
  133. dict for {option value} [dict remove $defaults alpha] {
  134. set $option [option get $w $option ""]
  135. if {[set $option] eq ""} {
  136. set $option $value
  137. }
  138. }
  139. set xpos [tk::ScaleNum 16]
  140. set ypos [tk::ScaleNum 48]
  141. # position from the "ne" corner
  142. wm geometry $w -$xpos+$ypos
  143. wm overrideredirect $w true
  144. # internal options
  145. option add *Sysnotify.Label.anchor w
  146. option add *Sysnotify.Label.justify left
  147. option add *Sysnotify.Label.wrapLength [expr {[winfo screenwidth .] / 4}]
  148. foreach option {padX padY foreground background} {
  149. option add *Sysnotify.Label.$option [set $option]
  150. }
  151. set icon ::tk::icons::information
  152. set width [expr {[image width $icon] + 2 * $padX}]
  153. set height [expr {[image height $icon] + 2 * $padY}]
  154. label $w.icon -image $icon -width $width -height $height -anchor c
  155. label $w.title -text $title -font TkHeadingFont
  156. label $w.message -text [_filterMarkup $msg] -font TkTooltipFont
  157. grid $w.icon $w.title -sticky news
  158. grid ^ $w.message -sticky news
  159. bind Sysnotify <Map> [namespace code {
  160. # set the wm attribute here; it is ignored if set
  161. # before the window is mapped
  162. wm attributes %W -alpha 0.0
  163. if {[wm attributes %W -alpha] == 0.0} {
  164. _fadeIn %W
  165. }
  166. }]
  167. bind Sysnotify <Enter> [namespace code {_onEnter %W}]
  168. bind Sysnotify <Leave> [namespace code {_onLeave %W}]
  169. bind $w <Button-1> [namespace code [list _notifyDestroy $w]]
  170. after $delay [namespace code [list _fadeOut $w]]
  171. return
  172. }
  173. # Fade the window into view.
  174. proc _fadeIn {w} {
  175. variable defaults
  176. if {![winfo exists $w]} {return}
  177. if {[set alpha [option get $w alpha ""]] eq ""} {
  178. set alpha [dict get $defaults alpha]
  179. }
  180. raise $w
  181. set before [wm attributes $w -alpha]
  182. set new [expr { min($alpha, $before + 0.10) }]
  183. wm attributes $w -alpha $new
  184. set after [wm attributes $w -alpha]
  185. if {($before == 1.0) || ($before == $after)} {
  186. # not supported or we're done
  187. return
  188. }
  189. after 40 [namespace code [list _fadeIn $w]]
  190. }
  191. # Fade out and destroy window.
  192. proc _fadeOut {w} {
  193. if {![winfo exists $w]} {return}
  194. set before [wm attributes $w -alpha]
  195. set new [expr { $before - 0.02 }]
  196. wm attributes $w -alpha $new
  197. set after [wm attributes $w -alpha]
  198. if {($after == 1.0) || ($before == $after)} {
  199. _notifyDestroy $w
  200. return
  201. }
  202. after 40 [namespace code [list _fadeOut $w]]
  203. }
  204. proc _notifyDestroy {w} {
  205. # cancel any pending fade in or fade out
  206. _cancelFading $w
  207. destroy $w
  208. }
  209. proc _onEnter {w} {
  210. wm attributes $w -alpha 1.0
  211. _cancelFading $w
  212. }
  213. proc _onLeave {w} {
  214. variable defaults
  215. if {[set alpha [option get $w alpha ""]] eq ""} {
  216. set alpha [dict get $defaults alpha]
  217. }
  218. if {[set delay [option get $w delay ""]] eq ""} {
  219. set delay [dict get $defaults delay]
  220. }
  221. wm attributes $w -alpha $alpha
  222. after $delay [namespace code [list _fadeOut $w]]
  223. }
  224. proc _cancelFading {w} {
  225. after cancel [namespace code [list _fadeOut $w]]
  226. after cancel [namespace code [list _fadeIn $w]]
  227. }
  228. # The Desktop Notifications Specification allow for some markup
  229. # in the message to display. It also specifies
  230. # "Notification servers that do not support these tags should
  231. # filter them out"
  232. # See https://specifications.freedesktop.org/notification-spec/latest/ar01s04.html
  233. # We don't event try to render those properly
  234. proc _filterMarkup {txt} {
  235. # remove fixed tags
  236. set maplist {<b> "" </b> "" <i> "" </i> "" <u> "" </u> "" </a> ""}
  237. set txt [string map $maplist $txt]
  238. # remove <img> tags leaving (possible) alt text
  239. set txt [regsub -- {<img *src="[^"]*" *(alt="([^"]*)")? */?>} $txt {\2}]
  240. # remove <a href=""> variable tag
  241. set txt [regsub -- {<a[^>]*>} $txt {}]
  242. return $txt
  243. }
  244. }
  245. # tk systray --
  246. # This procedure creates an icon display in the platform-specific system tray.
  247. #
  248. # Subcommands:
  249. #
  250. # create - create systray icon.
  251. # Arguments:
  252. # -image - Tk image to display.
  253. # -text - string to display in tooltip over image.
  254. # -button1 - Tcl proc to invoke on <Button-1> event.
  255. # -button3 - Tcl proc to invoke on <Button-3> event.
  256. #
  257. # configure - change one of the systray properties.
  258. # Arguments (Any or all can be called):
  259. # -image - Tk image to update.
  260. # -text - string to update.
  261. # -button1 - Tcl proc to change for <Button-1> event.
  262. # -button3 - Tcl proc to change for <Button-3> event.
  263. #
  264. # destroy - destroy systray icon.
  265. # Arguments:
  266. # none.
  267. proc ::tk::systray::create {args} {
  268. variable _created
  269. variable _options
  270. variable _current
  271. variable _ico
  272. if {$_created} {
  273. return -code error -errorcode {TK SYSTRAY CREATE} "only one system tray icon supported per interpeter"
  274. }
  275. _check_options $args 0
  276. if {![dict exists $args -image]} {
  277. return -code error -errorcode {TK SYSTRAY CREATE} "missing required option \"-image\""
  278. }
  279. set values [dict merge $_options $args]
  280. try {
  281. switch -- [tk windowingsystem] {
  282. "win32" {
  283. set _ico [_systray add -image [dict get $values -image] \
  284. -text [dict get $values -text] \
  285. -callback [list ::tk::systray::_win_callback %m]]
  286. }
  287. "x11" {
  288. _systray ._tray -image [dict get $values -image] -visible true
  289. _balloon ._tray [dict get $values -text]
  290. bind ._tray <Button-1> [dict get $values -button1]
  291. bind ._tray <Button-3> [dict get $values -button3]
  292. }
  293. "aqua" {
  294. _systray create [dict get $values -image] [dict get $values -text] \
  295. [dict get $values -button1] [dict get $values -button3]
  296. }
  297. }
  298. } on ok {} {
  299. set _current $values
  300. set _created 1
  301. return
  302. } on error {msg opts} {
  303. return -code error -errorcode [dict get $opts -errorcode] $msg
  304. }
  305. }
  306. # Modify the systray icon.
  307. proc ::tk::systray::configure {args} {
  308. variable _created
  309. variable _options
  310. variable _current
  311. variable _ico
  312. if {!$_created} {
  313. return -code error -errorcode {TK SYSTRAY CREATE} "systray not created"
  314. }
  315. _check_options $args 1
  316. if {[llength $args] == 0} {
  317. return $_current
  318. } elseif {[llength $args] == 1} {
  319. return [dict get $_current [lindex $args 0]]
  320. }
  321. set values [dict merge $_current $args]
  322. try {
  323. switch -- [tk windowingsystem] {
  324. "win32" {
  325. if {[dict exists $args -image]} {
  326. _systray modify $_ico -image [dict get $args -image]
  327. }
  328. if {[dict exists $args -text]} {
  329. _systray modify $_ico -text [dict get $args -text]
  330. }
  331. }
  332. "x11" {
  333. if {[dict exists $args -image]} {
  334. ._tray configure -image [dict get $args -image]
  335. }
  336. if {[dict exists $args -text]} {
  337. _balloon ._tray [dict get $args -text]
  338. }
  339. if {[dict exists $args -button1]} {
  340. bind ._tray <Button-1> [dict get $args -button1]
  341. }
  342. if {[dict exists $args -button3]} {
  343. bind ._tray <Button-3> [dict get $args -button3]
  344. }
  345. }
  346. "aqua" {
  347. foreach {key opt} {image -image text \
  348. -text b1_callback -button1 b3_callback -button3} {
  349. if {[dict exists $args $opt]} {
  350. _systray modify $key [dict get $args $opt]
  351. }
  352. }
  353. }
  354. }
  355. } on ok {} {
  356. set _current $values
  357. return
  358. } on error {msg opts} {
  359. return -code error -errorcode [dict get $opts -errorcode] $msg
  360. }
  361. }
  362. # Remove the systray icon.
  363. proc ::tk::systray::destroy {} {
  364. variable _created
  365. variable _current
  366. variable _ico
  367. if {!$_created} {
  368. return -code error -errorcode {TK SYSTRAY DESTROY} "systray not created"
  369. }
  370. switch -- [tk windowingsystem] {
  371. "win32" {
  372. _systray delete $_ico
  373. set _ico ""
  374. }
  375. "x11" {
  376. ::destroy ._tray
  377. }
  378. "aqua" {
  379. _systray destroy
  380. }
  381. }
  382. set _created 0
  383. set _current {}
  384. return
  385. }
  386. # Check systray icon existence.
  387. proc tk::systray::exists {} {
  388. variable _created
  389. return $_created
  390. }
  391. # Check systray options
  392. proc ::tk::systray::_check_options {argsList singleOk} {
  393. variable _options
  394. set len [llength $argsList]
  395. while {[llength $argsList] > 0} {
  396. set opt [lindex $argsList 0]
  397. if {![dict exists $_options $opt]} {
  398. tailcall return -code error -errorcode {TK SYSTRAY OPTION} \
  399. "unknown option \"$opt\": must be -image, -text, -button1 or -button3"
  400. }
  401. if {[llength $argsList] == 1 && !($len == 1 && $singleOk)} {
  402. tailcall return -code error -errorcode {TK SYSTRAY OPTION} \
  403. "missing value for option \"$opt\""
  404. }
  405. set argsList [lrange $argsList 2 end]
  406. }
  407. }
  408. # tk sysnotify --
  409. # This procedure implements a platform-specific system notification alert.
  410. #
  411. # Arguments:
  412. # title - main text of alert.
  413. # message - body text of alert.
  414. proc ::tk::sysnotify::sysnotify {title message} {
  415. switch -- [tk windowingsystem] {
  416. "win32" {
  417. if {!$::tk::systray::_created} {
  418. error "must create a system tray icon with the \"tk systray\" command first"
  419. }
  420. _sysnotify notify $::tk::systray::_ico $title $message
  421. }
  422. "x11" {
  423. if {[info commands ::tk::sysnotify::_sysnotify] eq ""} {
  424. _notifywindow $title $message
  425. } else {
  426. _sysnotify $title $message
  427. }
  428. }
  429. "aqua" {
  430. _sysnotify $title $message
  431. }
  432. }
  433. return
  434. }
  435. #Add these commands to the tk command ensemble: tk systray, tk sysnotify
  436. #Thanks to Christian Gollwitzer for the guidance here
  437. namespace ensemble configure tk -map \
  438. [dict merge [namespace ensemble configure tk -map] \
  439. {systray ::tk::systray sysnotify ::tk::sysnotify::sysnotify}]