Skip to content

Commit d175d8c

Browse files
committed
feat: Desktop notifications (fixes #499)
This only adds support for OSC 777 terminals, but that seems to include ghostty, wezterm, and probably a number of others. A demo is included.
1 parent 4d21ceb commit d175d8c

File tree

5 files changed

+171
-53
lines changed

5 files changed

+171
-53
lines changed

_demos/notify.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
//go:build ignore
2+
// +build ignore
3+
4+
// Copyright 2025 The TCell Authors
5+
//
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use file except in compliance with the License.
8+
// You may obtain a copy of the license at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
18+
package main
19+
20+
import (
21+
"fmt"
22+
"os"
23+
"time"
24+
25+
"github.com/gdamore/tcell/v3"
26+
)
27+
28+
func displayHelloWorld(s tcell.Screen, secs int) {
29+
w, h := s.Size()
30+
s.Clear()
31+
style := tcell.StyleDefault.Foreground(tcell.ColorCadetBlue.TrueColor()).Background(tcell.ColorWhite)
32+
msg := "Notification Demo"
33+
s.PutStrStyled((w-len(msg))/2, h/2-1, msg, style)
34+
msg = "(Minimize This Window)"
35+
s.PutStrStyled((w-len(msg))/2, h/2+1, msg, style)
36+
if secs > 0 {
37+
msg = fmt.Sprintf("Incoming in %d Seconds", secs)
38+
} else {
39+
msg = "Notification Sent!"
40+
}
41+
s.PutStr((w-len(msg))/2, h/2+3, msg)
42+
msg = "Press ESC to exit, ENTER to restart."
43+
s.PutStr((w-len(msg))/2, h/2+5, msg)
44+
s.Show()
45+
}
46+
47+
// This program just prints "Hello, World!". Press ESC to exit.
48+
func main() {
49+
50+
s, e := tcell.NewScreen()
51+
if e != nil {
52+
fmt.Fprintf(os.Stderr, "%v\n", e)
53+
os.Exit(1)
54+
}
55+
if e := s.Init(); e != nil {
56+
fmt.Fprintf(os.Stderr, "%v\n", e)
57+
os.Exit(1)
58+
}
59+
60+
defStyle := tcell.StyleDefault.
61+
Background(tcell.ColorBlack).
62+
Foreground(tcell.ColorWhite)
63+
s.SetStyle(defStyle)
64+
65+
when := 10
66+
displayHelloWorld(s, when)
67+
68+
ticker := time.NewTicker(time.Second)
69+
var ev tcell.Event
70+
for {
71+
select {
72+
case ev = <-s.EventQ():
73+
case <-ticker.C:
74+
if when > 0 {
75+
when--
76+
if when == 0 {
77+
s.ShowNotification("Ding Dong!", "The wicked witch is dead.")
78+
}
79+
}
80+
displayHelloWorld(s, when)
81+
continue
82+
}
83+
switch ev := ev.(type) {
84+
case *tcell.EventResize:
85+
s.Sync()
86+
case *tcell.EventKey:
87+
switch ev.Key() {
88+
case tcell.KeyEnter:
89+
when = 10
90+
case tcell.KeyEscape:
91+
s.Fini()
92+
os.Exit(0)
93+
}
94+
}
95+
displayHelloWorld(s, when)
96+
}
97+
}

screen.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,10 @@ type Screen interface {
233233
// EventPaste with the clipboard content as the Data() field. Terminals may
234234
// prevent this for security reasons.
235235
GetClipboard()
236+
237+
// ShowNotification is used to show a desktop notification, when the terminal
238+
// supports it. Right now only terminals supporting OSC 777 support this.
239+
ShowNotification(title string, body string)
236240
}
237241

238242
// NewScreen returns a default Screen suitable for the user's terminal
@@ -300,6 +304,7 @@ type screenImpl interface {
300304
Tty() (Tty, bool)
301305
SetClipboard([]byte)
302306
GetClipboard()
307+
ShowNotification(string, string)
303308

304309
// Following methods are not part of the Screen api, but are used for interaction with
305310
// the common layer code.

simulation.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,3 +502,5 @@ func (s *simscreen) GetClipboard() {
502502
func (s *simscreen) GetClipboardData() []byte {
503503
return s.clipboard
504504
}
505+
506+
func (s *simscreen) ShowNotification(_ string, _ string) {}

tscreen.go

Lines changed: 63 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -120,57 +120,58 @@ func NewTerminfoScreenFromTty(tty Tty) (Screen, error) {
120120

121121
// tScreen represents a screen backed by a terminfo implementation.
122122
type tScreen struct {
123-
tty Tty
124-
h int
125-
w int
126-
fini bool
127-
cells CellBuffer
128-
buffering bool // true if we are collecting writes to buf instead of sending directly to out
129-
buf bytes.Buffer
130-
curstyle Style
131-
style Style
132-
resizeQ chan bool
133-
quit chan struct{}
134-
keychan chan []byte
135-
cx int
136-
cy int
137-
clear bool
138-
cursorx int
139-
cursory int
140-
acs map[rune]string
141-
charset string
142-
encoder transform.Transformer
143-
decoder transform.Transformer
144-
fallback map[rune]string
145-
ncolor int
146-
colors map[Color]Color
147-
palette []Color
148-
truecolor bool
149-
legacy bool
150-
finiOnce sync.Once
151-
enterUrl string
152-
exitUrl string
153-
setWinSize string
154-
cursorStyles map[CursorStyle]string
155-
cursorStyle CursorStyle
156-
cursorColor Color
157-
cursorRGB string
158-
cursorFg string
159-
stopQ chan struct{}
160-
eventQ chan Event
161-
running bool
162-
wg sync.WaitGroup
163-
mouseFlags MouseFlags
164-
pasteEnabled bool
165-
focusEnabled bool
166-
setTitle string
167-
saveTitle string
168-
restoreTitle string
169-
title string
170-
setClipboard string
171-
enableCsiU string
172-
disableCsiU string
173-
input *inputProcessor
123+
tty Tty
124+
h int
125+
w int
126+
fini bool
127+
cells CellBuffer
128+
buffering bool // true if we are collecting writes to buf instead of sending directly to out
129+
buf bytes.Buffer
130+
curstyle Style
131+
style Style
132+
resizeQ chan bool
133+
quit chan struct{}
134+
keychan chan []byte
135+
cx int
136+
cy int
137+
clear bool
138+
cursorx int
139+
cursory int
140+
acs map[rune]string
141+
charset string
142+
encoder transform.Transformer
143+
decoder transform.Transformer
144+
fallback map[rune]string
145+
ncolor int
146+
colors map[Color]Color
147+
palette []Color
148+
truecolor bool
149+
legacy bool
150+
finiOnce sync.Once
151+
enterUrl string
152+
exitUrl string
153+
setWinSize string
154+
cursorStyles map[CursorStyle]string
155+
cursorStyle CursorStyle
156+
cursorColor Color
157+
cursorRGB string
158+
cursorFg string
159+
stopQ chan struct{}
160+
eventQ chan Event
161+
running bool
162+
wg sync.WaitGroup
163+
mouseFlags MouseFlags
164+
pasteEnabled bool
165+
focusEnabled bool
166+
setTitle string
167+
saveTitle string
168+
restoreTitle string
169+
title string
170+
setClipboard string
171+
notifyDesktop string
172+
enableCsiU string
173+
disableCsiU string
174+
input *inputProcessor
174175

175176
sync.Mutex
176177
}
@@ -293,6 +294,11 @@ func (t *tScreen) prepareExtendedOSC() {
293294
// sent string, when we support that.
294295
t.setClipboard = "\x1b]52;c;%s\x1b\\"
295296

297+
// OSC 777 is the desktop notification supported by a variety of
298+
// newer terminals. (There was also OSC 9 and OSC 99, but they
299+
// are not as widely deployed, and OSC 9 is not unique.)
300+
t.notifyDesktop = "\x1b]777;notify;%s;%s\x1b\\"
301+
296302
if t.enableCsiU == "" {
297303
if runtime.GOOS == "windows" && (os.Getenv("TERM") == "" || os.Getenv("TERM_PROGRAM") == "WezTerm") {
298304
// on Windows, if we don't have a TERM, use only win32-input-mode
@@ -1179,3 +1185,9 @@ func (t *tScreen) GetClipboard() {
11791185
}
11801186
t.Unlock()
11811187
}
1188+
1189+
func (t *tScreen) ShowNotification(title string, body string) {
1190+
t.Lock()
1191+
t.Printf(t.notifyDesktop, title, body)
1192+
t.Unlock()
1193+
}

wscreen.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ func (t *wScreen) postEvent(ev Event) {
309309
}
310310
}
311311

312-
func (t *wScreen) onMouseEvent(this js.Value, args []js.Value) interface{} {
312+
func (t *wScreen) onMouseEvent(this js.Value, args []js.Value) any {
313313
mod := ModNone
314314
button := ButtonNone
315315

@@ -395,7 +395,7 @@ func (t *wScreen) onFocus(this js.Value, args []js.Value) any {
395395
// happen when javascript calls a function (for example, when
396396
// mouse input is disabled, when onMouseEvent() is called from
397397
// js, it redirects here and does nothing).
398-
func (t *wScreen) unset(this js.Value, args []js.Value) interface{} {
398+
func (t *wScreen) unset(this js.Value, args []js.Value) any {
399399
return nil
400400
}
401401

@@ -496,6 +496,8 @@ func (t *wScreen) SetTitle(title string) {
496496
js.Global().Call("setTitle", title)
497497
}
498498

499+
func (*wScreen) ShowNotification(title string, body string) {}
500+
499501
// WebKeyNames maps string names reported from HTML
500502
// (KeyboardEvent.key) to tcell accepted keys.
501503
var WebKeyNames = map[string]Key{

0 commit comments

Comments
 (0)