Skip to content

Commit a3e1662

Browse files
feat: implement OSC 1 (set icon name) and fix icon name push/pop
Add iconName field and setIconName method to InputHandler. Register OSC 1 handler and update OSC 0 to set both title and icon name. Fix CSI 22/23 (windowOptions) to push/pop the actual icon name instead of the window title, and restore icon name state on pop instead of discarding it. Expose IconName() accessor and OnIconNameChange event on Terminal. Fixes #16 Co-authored-by: Ona <no-reply@ona.com>
1 parent 1da4d6b commit a3e1662

7 files changed

Lines changed: 233 additions & 13 deletions

inputhandler.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,15 @@ type InputHandler struct {
8383
parseBuffer []uint32
8484
dirtyRowTracker *DirtyRowTracker
8585

86-
windowTitle string
86+
windowTitle string
87+
iconName string
8788
windowTitleStack []string
8889
iconNameStack []string
8990

9091
// Events
9192
OnCursorMoveEmitter EventEmitter[struct{}]
9293
OnTitleChangeEmitter EventEmitter[string]
94+
OnIconNameChangeEmitter EventEmitter[string]
9395
OnLineFeedEmitter EventEmitter[struct{}]
9496
OnA11yCharEmitter EventEmitter[string]
9597
OnA11yTabEmitter EventEmitter[int]
@@ -250,8 +252,9 @@ func NewInputHandler(
250252
p.RegisterDcsHandler(FunctionIdentifier{Intermediates: "$", Final: 'q'}, NewDcsStringHandler(h.requestStatusString))
251253

252254
// OSC handlers
253-
p.RegisterOscHandler(0, NewOscStringHandler(h.SetTitle)) // OSC 0 — set title + icon
254-
p.RegisterOscHandler(2, NewOscStringHandler(h.SetTitle)) // OSC 2 — set title
255+
p.RegisterOscHandler(0, NewOscStringHandler(h.setTitleAndIconName)) // OSC 0 — set title + icon name
256+
p.RegisterOscHandler(1, NewOscStringHandler(h.setIconName)) // OSC 1 — set icon name
257+
p.RegisterOscHandler(2, NewOscStringHandler(h.SetTitle)) // OSC 2 — set title
255258
p.RegisterOscHandler(4, NewOscStringHandler(h.SetOrReportIndexedColor))
256259
p.RegisterOscHandler(8, NewOscStringHandler(h.SetHyperlink))
257260
p.RegisterOscHandler(10, NewOscStringHandler(h.SetOrReportFgColor))
@@ -530,6 +533,7 @@ func (h *InputHandler) Dispose() {
530533
h.parser.Dispose()
531534
h.OnCursorMoveEmitter.Dispose()
532535
h.OnTitleChangeEmitter.Dispose()
536+
h.OnIconNameChangeEmitter.Dispose()
533537
h.OnLineFeedEmitter.Dispose()
534538
h.OnA11yCharEmitter.Dispose()
535539
h.OnA11yTabEmitter.Dispose()

inputhandler_csi.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -927,8 +927,7 @@ func (h *InputHandler) windowOptions(params *Params) bool {
927927
h.windowTitleStack = append(h.windowTitleStack, h.windowTitle)
928928
}
929929
if ps2 == 0 || ps2 == 1 {
930-
// Icon name shares the window title in this implementation.
931-
h.iconNameStack = append(h.iconNameStack, h.windowTitle)
930+
h.iconNameStack = append(h.iconNameStack, h.iconName)
932931
}
933932
case 23:
934933
// Pop title from stack.
@@ -946,7 +945,10 @@ func (h *InputHandler) windowOptions(params *Params) bool {
946945
}
947946
if ps2 == 0 || ps2 == 1 {
948947
if len(h.iconNameStack) > 0 {
948+
name := h.iconNameStack[len(h.iconNameStack)-1]
949949
h.iconNameStack = h.iconNameStack[:len(h.iconNameStack)-1]
950+
h.iconName = name
951+
h.OnIconNameChangeEmitter.Fire(name)
950952
}
951953
}
952954
default:

inputhandler_csi_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,5 +1401,80 @@ func TestWindowOptionsUnknownSubcommand(t *testing.T) {
14011401
}
14021402
}
14031403

1404+
func TestWindowOptionsPushPopIconName(t *testing.T) {
1405+
t.Parallel()
1406+
h := newTestInputHandler(80, 24)
1407+
var lastIconName string
1408+
h.OnIconNameChangeEmitter.Event(func(name string) {
1409+
lastIconName = name
1410+
})
1411+
1412+
// Set icon name via OSC 1.
1413+
h.ParseString("\x1b]1;first-icon\x07")
1414+
if lastIconName != "first-icon" {
1415+
t.Fatalf("expected icon name %q, got %q", "first-icon", lastIconName)
1416+
}
1417+
1418+
// Push icon name only (CSI 22;1t).
1419+
h.ParseString("\x1b[22;1t")
1420+
1421+
// Change icon name.
1422+
h.ParseString("\x1b]1;second-icon\x07")
1423+
if lastIconName != "second-icon" {
1424+
t.Fatalf("expected icon name %q, got %q", "second-icon", lastIconName)
1425+
}
1426+
1427+
// Pop icon name only (CSI 23;1t).
1428+
h.ParseString("\x1b[23;1t")
1429+
if lastIconName != "first-icon" {
1430+
t.Errorf("expected icon name %q after pop, got %q", "first-icon", lastIconName)
1431+
}
1432+
}
1433+
1434+
func TestWindowOptionsPushPopIconNameIndependentOfTitle(t *testing.T) {
1435+
t.Parallel()
1436+
h := newTestInputHandler(80, 24)
1437+
var lastTitle, lastIconName string
1438+
h.OnTitleChangeEmitter.Event(func(s string) { lastTitle = s })
1439+
h.OnIconNameChangeEmitter.Event(func(s string) { lastIconName = s })
1440+
1441+
// Set different title and icon name.
1442+
h.ParseString("\x1b]2;my-title\x1b\\")
1443+
h.ParseString("\x1b]1;my-icon\x07")
14041444

1445+
// Push both (CSI 22;0t).
1446+
h.ParseString("\x1b[22;0t")
1447+
1448+
// Change both.
1449+
h.ParseString("\x1b]2;new-title\x1b\\")
1450+
h.ParseString("\x1b]1;new-icon\x07")
1451+
1452+
// Pop both (CSI 23;0t).
1453+
h.ParseString("\x1b[23;0t")
1454+
if lastTitle != "my-title" {
1455+
t.Errorf("expected title %q, got %q", "my-title", lastTitle)
1456+
}
1457+
if lastIconName != "my-icon" {
1458+
t.Errorf("expected icon name %q, got %q", "my-icon", lastIconName)
1459+
}
1460+
}
1461+
1462+
func TestWindowOptionsPopIconNameEmptyStack(t *testing.T) {
1463+
t.Parallel()
1464+
h := newTestInputHandler(80, 24)
1465+
iconNameChanges := 0
1466+
h.OnIconNameChangeEmitter.Event(func(string) {
1467+
iconNameChanges++
1468+
})
1469+
1470+
// Set initial icon name.
1471+
h.ParseString("\x1b]1;initial\x07")
1472+
iconNameChanges = 0
1473+
1474+
// Pop from empty stack — should not fire event.
1475+
h.ParseString("\x1b[23;1t")
1476+
if iconNameChanges != 0 {
1477+
t.Errorf("expected no icon name change on empty stack pop, got %d changes", iconNameChanges)
1478+
}
1479+
}
14051480

inputhandler_osc.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,27 @@ var specialColors = []SpecialColorIndex{
1515
SpecialColorCursor,
1616
}
1717

18-
// SetTitle (OSC 0, OSC 2) — set the terminal window title.
18+
// SetTitle (OSC 2) — set the terminal window title.
1919
func (h *InputHandler) SetTitle(data string) bool {
2020
h.windowTitle = data
2121
h.OnTitleChangeEmitter.Fire(data)
2222
return true
2323
}
2424

25+
// setTitleAndIconName (OSC 0) — set both the window title and icon name.
26+
func (h *InputHandler) setTitleAndIconName(data string) bool {
27+
h.SetTitle(data)
28+
h.setIconName(data)
29+
return true
30+
}
31+
32+
// setIconName (OSC 1) — set the terminal icon name.
33+
func (h *InputHandler) setIconName(data string) bool {
34+
h.iconName = data
35+
h.OnIconNameChangeEmitter.Fire(data)
36+
return true
37+
}
38+
2539
// SetOrReportIndexedColor (OSC 4) — set or query palette colors.
2640
func (h *InputHandler) SetOrReportIndexedColor(data string) bool {
2741
var events []ColorEvent

inputhandler_osc_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,87 @@ func TestOSC_SetTitle_Empty(t *testing.T) {
6363
}
6464
}
6565

66+
// --- OSC icon name tests ---
67+
68+
func TestOSC1_SetIconName(t *testing.T) {
69+
t.Parallel()
70+
h := newTestHandler()
71+
var iconName string
72+
h.OnIconNameChangeEmitter.Event(func(s string) { iconName = s })
73+
74+
// OSC 1 ; <name> BEL
75+
h.ParseString("\x1b]1;my-icon\x07")
76+
77+
if iconName != "my-icon" {
78+
t.Errorf("expected icon name 'my-icon', got %q", iconName)
79+
}
80+
if h.iconName != "my-icon" {
81+
t.Errorf("expected h.iconName 'my-icon', got %q", h.iconName)
82+
}
83+
}
84+
85+
func TestOSC1_SetIconName_ST(t *testing.T) {
86+
t.Parallel()
87+
h := newTestHandler()
88+
var iconName string
89+
h.OnIconNameChangeEmitter.Event(func(s string) { iconName = s })
90+
91+
// OSC 1 ; <name> ST (ESC \)
92+
h.ParseString("\x1b]1;icon-st\x1b\\")
93+
94+
if iconName != "icon-st" {
95+
t.Errorf("expected icon name 'icon-st', got %q", iconName)
96+
}
97+
}
98+
99+
func TestOSC1_SetIconName_Empty(t *testing.T) {
100+
t.Parallel()
101+
h := newTestHandler()
102+
// Set a non-empty icon name first.
103+
h.ParseString("\x1b]1;something\x07")
104+
105+
var iconName string
106+
h.OnIconNameChangeEmitter.Event(func(s string) { iconName = s })
107+
108+
h.ParseString("\x1b]1;\x07")
109+
110+
if iconName != "" {
111+
t.Errorf("expected empty icon name, got %q", iconName)
112+
}
113+
}
114+
115+
func TestOSC0_SetsTitleAndIconName(t *testing.T) {
116+
t.Parallel()
117+
h := newTestHandler()
118+
var title, iconName string
119+
h.OnTitleChangeEmitter.Event(func(s string) { title = s })
120+
h.OnIconNameChangeEmitter.Event(func(s string) { iconName = s })
121+
122+
// OSC 0 should set both title and icon name.
123+
h.ParseString("\x1b]0;both-value\x07")
124+
125+
if title != "both-value" {
126+
t.Errorf("expected title 'both-value', got %q", title)
127+
}
128+
if iconName != "both-value" {
129+
t.Errorf("expected icon name 'both-value', got %q", iconName)
130+
}
131+
}
132+
133+
func TestOSC2_DoesNotSetIconName(t *testing.T) {
134+
t.Parallel()
135+
h := newTestHandler()
136+
iconNameChanged := false
137+
h.OnIconNameChangeEmitter.Event(func(s string) { iconNameChanged = true })
138+
139+
// OSC 2 should only set title, not icon name.
140+
h.ParseString("\x1b]2;title-only\x07")
141+
142+
if iconNameChanged {
143+
t.Error("OSC 2 should not fire icon name change event")
144+
}
145+
}
146+
66147
// --- OSC color tests ---
67148

68149
func TestOSC4_SetIndexedColor(t *testing.T) {

terminal.go

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,14 @@ type Terminal struct {
3434
inputHandler *InputHandler
3535

3636
// Public event emitters (forwarded from sub-components).
37-
OnBellEmitter EventEmitter[struct{}]
38-
OnTitleChangeEmitter EventEmitter[string]
39-
OnLineFeedEmitter EventEmitter[struct{}]
40-
OnCursorMoveEmitter EventEmitter[struct{}]
41-
OnResizeEmitter EventEmitter[BufferResizeEvent]
42-
OnScrollEmitter EventEmitter[int]
43-
OnRenderEmitter EventEmitter[RowRange]
37+
OnBellEmitter EventEmitter[struct{}]
38+
OnTitleChangeEmitter EventEmitter[string]
39+
OnIconNameChangeEmitter EventEmitter[string]
40+
OnLineFeedEmitter EventEmitter[struct{}]
41+
OnCursorMoveEmitter EventEmitter[struct{}]
42+
OnResizeEmitter EventEmitter[BufferResizeEvent]
43+
OnScrollEmitter EventEmitter[int]
44+
OnRenderEmitter EventEmitter[RowRange]
4445
}
4546

4647
// New creates a new Terminal with the given options.
@@ -71,6 +72,7 @@ func New(opts ...Option) *Terminal {
7172
// Forward input handler events.
7273
ih.OnRequestBellEmitter.Event(func(struct{}) { t.OnBellEmitter.Fire(struct{}{}) })
7374
ih.OnTitleChangeEmitter.Event(func(s string) { t.OnTitleChangeEmitter.Fire(s) })
75+
ih.OnIconNameChangeEmitter.Event(func(s string) { t.OnIconNameChangeEmitter.Fire(s) })
7476
ih.OnLineFeedEmitter.Event(func(struct{}) { t.OnLineFeedEmitter.Fire(struct{}{}) })
7577
ih.OnCursorMoveEmitter.Event(func(struct{}) { t.OnCursorMoveEmitter.Fire(struct{}{}) })
7678
ih.OnRequestRefreshRowsEmitter.Event(func(r RowRange) { t.OnRenderEmitter.Fire(r) })
@@ -185,6 +187,14 @@ func (t *Terminal) OnTitleChange(fn func(string)) Disposable {
185187
return t.OnTitleChangeEmitter.Event(fn)
186188
}
187189

190+
// IconName returns the current icon name set via OSC 1 or OSC 0.
191+
func (t *Terminal) IconName() string { return t.inputHandler.iconName }
192+
193+
// OnIconNameChange registers a callback for icon name change events.
194+
func (t *Terminal) OnIconNameChange(fn func(string)) Disposable {
195+
return t.OnIconNameChangeEmitter.Event(fn)
196+
}
197+
188198
// OnLineFeed registers a callback for line feed events.
189199
func (t *Terminal) OnLineFeed(fn func()) Disposable {
190200
return t.OnLineFeedEmitter.Event(func(struct{}) { fn() })
@@ -239,6 +249,7 @@ func (t *Terminal) Dispose() {
239249
t.coreService.Dispose()
240250
t.OnBellEmitter.Dispose()
241251
t.OnTitleChangeEmitter.Dispose()
252+
t.OnIconNameChangeEmitter.Dispose()
242253
t.OnLineFeedEmitter.Dispose()
243254
t.OnCursorMoveEmitter.Dispose()
244255
t.OnResizeEmitter.Dispose()

terminal_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,39 @@ func TestTerminalTitleChange(t *testing.T) {
352352
}
353353
}
354354

355+
func TestTerminalIconNameChange(t *testing.T) {
356+
t.Parallel()
357+
term := newTestTerminal(80, 24)
358+
var iconName string
359+
term.OnIconNameChange(func(s string) { iconName = s })
360+
361+
// OSC 1 ; <name> BEL
362+
term.WriteString("\x1b]1;my-icon\x07")
363+
if iconName != "my-icon" {
364+
t.Errorf("iconName = %q, want %q", iconName, "my-icon")
365+
}
366+
if term.IconName() != "my-icon" {
367+
t.Errorf("IconName() = %q, want %q", term.IconName(), "my-icon")
368+
}
369+
}
370+
371+
func TestTerminalOSC0_SetsTitleAndIconName(t *testing.T) {
372+
t.Parallel()
373+
term := newTestTerminal(80, 24)
374+
var title, iconName string
375+
term.OnTitleChange(func(s string) { title = s })
376+
term.OnIconNameChange(func(s string) { iconName = s })
377+
378+
// OSC 0 should set both.
379+
term.WriteString("\x1b]0;both\x07")
380+
if title != "both" {
381+
t.Errorf("title = %q, want %q", title, "both")
382+
}
383+
if iconName != "both" {
384+
t.Errorf("iconName = %q, want %q", iconName, "both")
385+
}
386+
}
387+
355388
func TestTerminalBell(t *testing.T) {
356389
t.Parallel()
357390
term := newTestTerminal(80, 24)

0 commit comments

Comments
 (0)