feat(tray): dbusmenu menus
A right click (and the left-click fallbacks) on an item with a dbusmenu calls AboutToShow, Event(opened) and GetLayout, decodes the tree (mnemonics stripped, invisible items dropped, depth and item limits, children with another signature skipped) and sends it as a menu control line. menu-activate sends Event(clicked) and menu-closed sends Event(closed), only for the last menu and for ids it holds. Items without a dbusmenu keep ContextMenu.
- commit
- eaec4121d749ec2a314882fff9cb8544750983e7
- author
- brice · · 2026-10-09 05:29 UTC
- parent
- a4dc00a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +694 -11
M internal/tray/control.go +133 -10
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "errors" | |
| 9 | 9 | "io" | |
| 10 | 10 | "log/slog" | |
| 11 | + | "slices" | |
| 11 | 12 | "strconv" | |
| 12 | 13 | "strings" | |
| 13 | 14 | "sync" | |
| @@ -199,9 +200,10 @@ type control struct { | |||
| 199 | 200 | conn *zerobus.Conn | |
| 200 | 201 | done bool | |
| 201 | 202 | ||
| 202 | - | // The tooltip state belongs to the run goroutine. | |
| 203 | + | // The tooltip and menu state belong to the run goroutine. | |
| 203 | 204 | tipTimer *time.Timer | |
| 204 | 205 | hov hover | |
| 206 | + | menu *lastMenu | |
| 205 | 207 | } | |
| 206 | 208 | ||
| 207 | 209 | // hover is the icon the pointer rests on, and whether its tooltip is shown. | |
| @@ -311,18 +313,22 @@ func (c *control) handle(cmd command) { | |||
| 311 | 313 | switch cmd.button { | |
| 312 | 314 | case btnLeft: | |
| 313 | 315 | if tg.IsMenu { | |
| 314 | - | c.contextMenu(tg) | |
| 316 | + | c.openMenu(tg, cmd.token) | |
| 315 | 317 | return | |
| 316 | 318 | } | |
| 317 | 319 | err := c.call(tg, "Activate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) }) | |
| 318 | 320 | if isUnknownMethod(err) { | |
| 319 | - | c.contextMenu(tg) | |
| 321 | + | c.openMenu(tg, cmd.token) | |
| 320 | 322 | } | |
| 321 | 323 | case btnMiddle: | |
| 322 | 324 | c.call(tg, "SecondaryActivate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) }) | |
| 323 | 325 | case btnRight: | |
| 324 | - | c.contextMenu(tg) | |
| 326 | + | c.openMenu(tg, cmd.token) | |
| 325 | 327 | } | |
| 328 | + | case "menu-activate": | |
| 329 | + | c.menuActivate(cmd.token, int32(cmd.col)) // col carries the item id | |
| 330 | + | case "menu-closed": | |
| 331 | + | c.menuClosed(cmd.token) | |
| 326 | 332 | case "scroll": | |
| 327 | 333 | tg, ok := c.t.target(cmd.col) | |
| 328 | 334 | if !ok { | |
| @@ -383,7 +389,18 @@ func (c *control) emit(v any) { | |||
| 383 | 389 | } | |
| 384 | 390 | buf.Truncate(buf.Len() - 1) // Encode ends with a newline; the BEL goes first | |
| 385 | 391 | buf.WriteString(controlSuffix) | |
| 386 | - | if _, err := c.t.opt.Out.Write(buf.Bytes()); err != nil { | |
| 392 | + | c.write(buf.Bytes()) | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | // writeLine writes the control line whose JSON is b. | |
| 396 | + | func (c *control) writeLine(b []byte) { | |
| 397 | + | line := make([]byte, 0, len(controlPrefix)+len(b)+len(controlSuffix)) | |
| 398 | + | line = append(append(append(line, controlPrefix...), b...), controlSuffix...) | |
| 399 | + | c.write(line) | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | func (c *control) write(line []byte) { | |
| 403 | + | if _, err := c.t.opt.Out.Write(line); err != nil { | |
| 387 | 404 | c.log.Debug("tray: cannot write a control line", "err", err) | |
| 388 | 405 | } | |
| 389 | 406 | } | |
| @@ -402,6 +419,12 @@ func isUnknownMethod(err error) bool { | |||
| 402 | 419 | // reply is returned as is; a transport failure or a timeout drops the | |
| 403 | 420 | // connection and is logged. | |
| 404 | 421 | func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder)) error { | |
| 422 | + | return c.callOn(tg.Dest, tg.Path, itemIface, member, sig, args, nil) | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | // callOn is call for any interface. reply, if set, reads the body of a | |
| 426 | + | // successful reply; it must not keep what it reads. | |
| 427 | + | func (c *control) callOn(dest, path, iface, member, sig string, args func(*zerobus.Encoder), reply func(*zerobus.Message)) error { | |
| 405 | 428 | conn, err := c.conn2() | |
| 406 | 429 | if err != nil { | |
| 407 | 430 | if !errors.Is(err, zerobus.ErrClosed) { | |
| @@ -409,12 +432,17 @@ func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder | |||
| 409 | 432 | } | |
| 410 | 433 | return err | |
| 411 | 434 | } | |
| 412 | - | args(conn.NewCall(tg.Dest, tg.Path, itemIface, member, sig)) | |
| 435 | + | args(conn.NewCall(dest, path, iface, member, sig)) | |
| 413 | 436 | timer := time.AfterFunc(callTimeout, func() { _ = conn.Close() }) | |
| 414 | - | _, err = conn.Call() | |
| 437 | + | m, err := conn.Call() | |
| 438 | + | if err == nil && reply != nil { | |
| 439 | + | reply(m) | |
| 440 | + | } | |
| 415 | 441 | expired := !timer.Stop() | |
| 416 | - | var reply *zerobus.Error | |
| 417 | - | if errors.As(err, &reply) && !expired { | |
| 442 | + | var refused *zerobus.Error | |
| 443 | + | if (errors.As(err, &refused) || errors.Is(err, zerobus.ErrInvalid)) && !expired { | |
| 444 | + | // An error reply, or a name or path the library would not send: | |
| 445 | + | // the connection is fine. | |
| 418 | 446 | c.log.Debug("tray: the application refused", "method", member, "err", err) | |
| 419 | 447 | return err | |
| 420 | 448 | } | |
| @@ -427,8 +455,103 @@ func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder | |||
| 427 | 455 | stopping := c.done | |
| 428 | 456 | c.mu.Unlock() | |
| 429 | 457 | if !stopping { | |
| 430 | - | c.log.Warn("tray: application did not answer", "method", member, "dest", tg.Dest, "err", err) | |
| 458 | + | c.log.Warn("tray: application did not answer", "method", member, "dest", dest, "err", err) | |
| 431 | 459 | } | |
| 432 | 460 | } | |
| 433 | 461 | return err | |
| 434 | 462 | } | |
| 463 | + | ||
| 464 | + | // transportFailed reports whether err means the call did not complete, as | |
| 465 | + | // opposed to the application answering with an error. | |
| 466 | + | func transportFailed(err error) bool { | |
| 467 | + | var refused *zerobus.Error | |
| 468 | + | return err != nil && !errors.As(err, &refused) && !errors.Is(err, zerobus.ErrInvalid) | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | // openMenu shows the application's menu for the press numbered token: its | |
| 472 | + | // dbusmenu when it has one, else it asks the application to show its own. | |
| 473 | + | func (c *control) openMenu(tg Target, token uint32) { | |
| 474 | + | if tg.Menu == "" || tg.Menu == noMenuPath { | |
| 475 | + | c.contextMenu(tg) | |
| 476 | + | return | |
| 477 | + | } | |
| 478 | + | // Ignore refusals: many applications do not implement these. | |
| 479 | + | err := c.callOn(tg.Dest, tg.Menu, menuIface, "AboutToShow", "i", func(e *zerobus.Encoder) { e.Int32(0) }, nil) | |
| 480 | + | if transportFailed(err) { | |
| 481 | + | return | |
| 482 | + | } | |
| 483 | + | err = c.menuEvent(tg.Dest, tg.Menu, 0, "opened") | |
| 484 | + | if transportFailed(err) { | |
| 485 | + | return | |
| 486 | + | } | |
| 487 | + | var items []menuItem | |
| 488 | + | var ids []int32 | |
| 489 | + | var decodeErr error | |
| 490 | + | err = c.callOn(tg.Dest, tg.Menu, menuIface, "GetLayout", "iias", func(e *zerobus.Encoder) { | |
| 491 | + | e.Int32(0) | |
| 492 | + | e.Int32(-1) | |
| 493 | + | a := e.BeginArray('s') | |
| 494 | + | e.EndArray(a) | |
| 495 | + | }, func(m *zerobus.Message) { | |
| 496 | + | if m.Signature != "u"+layoutSig { | |
| 497 | + | decodeErr = errors.New("unexpected layout signature " + m.Signature) | |
| 498 | + | return | |
| 499 | + | } | |
| 500 | + | items, ids, decodeErr = decodeLayout(m.Body()) | |
| 501 | + | }) | |
| 502 | + | if err != nil { | |
| 503 | + | return | |
| 504 | + | } | |
| 505 | + | if decodeErr != nil { | |
| 506 | + | c.log.Warn("tray: unreadable menu", "dest", tg.Dest, "err", decodeErr) | |
| 507 | + | return | |
| 508 | + | } | |
| 509 | + | if len(items) == 0 { | |
| 510 | + | c.log.Debug("tray: the menu is empty", "dest", tg.Dest) | |
| 511 | + | return | |
| 512 | + | } | |
| 513 | + | line, ids, encErr := encodeMenu(menuLine{Type: "menu", Col: tg.Start, Width: tg.Width, Click: token, Items: items}) | |
| 514 | + | if encErr != nil { | |
| 515 | + | c.log.Warn("tray: cannot send the menu", "dest", tg.Dest, "err", encErr) | |
| 516 | + | return | |
| 517 | + | } | |
| 518 | + | c.menu = &lastMenu{token: token, dest: tg.Dest, path: tg.Menu, ids: ids} | |
| 519 | + | c.writeLine(line) | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | // menuEvent sends a dbusmenu Event with no data. | |
| 523 | + | func (c *control) menuEvent(dest, path string, id int32, event string) error { | |
| 524 | + | return c.callOn(dest, path, menuIface, "Event", "isvu", func(e *zerobus.Encoder) { | |
| 525 | + | e.Int32(id) | |
| 526 | + | e.Str(event) | |
| 527 | + | e.Variant("i") | |
| 528 | + | e.Int32(0) | |
| 529 | + | e.Uint32(0) | |
| 530 | + | }, nil) | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | // menuActivate carries out a choice of the bar: only for the menu it was last | |
| 534 | + | // given, and only for an item that menu holds. | |
| 535 | + | func (c *control) menuActivate(token uint32, id int32) { | |
| 536 | + | m := c.menu | |
| 537 | + | if m == nil || m.token != token { | |
| 538 | + | c.log.Debug("tray: ignoring a choice for an unknown menu", "token", token) | |
| 539 | + | return | |
| 540 | + | } | |
| 541 | + | if _, found := slices.BinarySearch(m.ids, id); !found { | |
| 542 | + | c.log.Debug("tray: ignoring a choice that is not in the menu", "token", token, "id", id) | |
| 543 | + | return | |
| 544 | + | } | |
| 545 | + | c.menu = nil | |
| 546 | + | c.menuEvent(m.dest, m.path, id, "clicked") | |
| 547 | + | } | |
| 548 | + | ||
| 549 | + | // menuClosed tells the application its menu is closed. | |
| 550 | + | func (c *control) menuClosed(token uint32) { | |
| 551 | + | m := c.menu | |
| 552 | + | if m == nil || m.token != token { | |
| 553 | + | return | |
| 554 | + | } | |
| 555 | + | c.menu = nil | |
| 556 | + | c.menuEvent(m.dest, m.path, 0, "closed") | |
| 557 | + | } | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "errors" | 8 | "errors" | ||
| 9 | "io" | 9 | "io" | ||
| 10 | "log/slog" | 10 | "log/slog" | ||
| 11 | + | "slices" | |||
| 11 | "strconv" | 12 | "strconv" | ||
| 12 | "strings" | 13 | "strings" | ||
| 13 | "sync" | 14 | "sync" | ||
| @@ -199,9 +200,10 @@ type control struct { | |||||
| 199 | conn *zerobus.Conn | 200 | conn *zerobus.Conn | ||
| 200 | done bool | 201 | done bool | ||
| 201 | 202 | ||||
| 202 | - | // The tooltip state belongs to the run goroutine. | 203 | + | // The tooltip and menu state belong to the run goroutine. |
| 203 | tipTimer *time.Timer | 204 | tipTimer *time.Timer | ||
| 204 | hov hover | 205 | hov hover | ||
| 206 | + | menu *lastMenu | |||
| 205 | } | 207 | } | ||
| 206 | 208 | ||||
| 207 | // hover is the icon the pointer rests on, and whether its tooltip is shown. | 209 | // hover is the icon the pointer rests on, and whether its tooltip is shown. | ||
| @@ -311,18 +313,22 @@ func (c *control) handle(cmd command) { | |||||
| 311 | switch cmd.button { | 313 | switch cmd.button { | ||
| 312 | case btnLeft: | 314 | case btnLeft: | ||
| 313 | if tg.IsMenu { | 315 | if tg.IsMenu { | ||
| 314 | - | c.contextMenu(tg) | 316 | + | c.openMenu(tg, cmd.token) |
| 315 | return | 317 | return | ||
| 316 | } | 318 | } | ||
| 317 | err := c.call(tg, "Activate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) }) | 319 | err := c.call(tg, "Activate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) }) | ||
| 318 | if isUnknownMethod(err) { | 320 | if isUnknownMethod(err) { | ||
| 319 | - | c.contextMenu(tg) | 321 | + | c.openMenu(tg, cmd.token) |
| 320 | } | 322 | } | ||
| 321 | case btnMiddle: | 323 | case btnMiddle: | ||
| 322 | c.call(tg, "SecondaryActivate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) }) | 324 | c.call(tg, "SecondaryActivate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) }) | ||
| 323 | case btnRight: | 325 | case btnRight: | ||
| 324 | - | c.contextMenu(tg) | 326 | + | c.openMenu(tg, cmd.token) |
| 325 | } | 327 | } | ||
| 328 | + | case "menu-activate": | |||
| 329 | + | c.menuActivate(cmd.token, int32(cmd.col)) // col carries the item id | |||
| 330 | + | case "menu-closed": | |||
| 331 | + | c.menuClosed(cmd.token) | |||
| 326 | case "scroll": | 332 | case "scroll": | ||
| 327 | tg, ok := c.t.target(cmd.col) | 333 | tg, ok := c.t.target(cmd.col) | ||
| 328 | if !ok { | 334 | if !ok { | ||
| @@ -383,7 +389,18 @@ func (c *control) emit(v any) { | |||||
| 383 | } | 389 | } | ||
| 384 | buf.Truncate(buf.Len() - 1) // Encode ends with a newline; the BEL goes first | 390 | buf.Truncate(buf.Len() - 1) // Encode ends with a newline; the BEL goes first | ||
| 385 | buf.WriteString(controlSuffix) | 391 | buf.WriteString(controlSuffix) | ||
| 386 | - | if _, err := c.t.opt.Out.Write(buf.Bytes()); err != nil { | 392 | + | c.write(buf.Bytes()) |
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | // writeLine writes the control line whose JSON is b. | |||
| 396 | + | func (c *control) writeLine(b []byte) { | |||
| 397 | + | line := make([]byte, 0, len(controlPrefix)+len(b)+len(controlSuffix)) | |||
| 398 | + | line = append(append(append(line, controlPrefix...), b...), controlSuffix...) | |||
| 399 | + | c.write(line) | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | func (c *control) write(line []byte) { | |||
| 403 | + | if _, err := c.t.opt.Out.Write(line); err != nil { | |||
| 387 | c.log.Debug("tray: cannot write a control line", "err", err) | 404 | c.log.Debug("tray: cannot write a control line", "err", err) | ||
| 388 | } | 405 | } | ||
| 389 | } | 406 | } | ||
| @@ -402,6 +419,12 @@ func isUnknownMethod(err error) bool { | |||||
| 402 | // reply is returned as is; a transport failure or a timeout drops the | 419 | // reply is returned as is; a transport failure or a timeout drops the | ||
| 403 | // connection and is logged. | 420 | // connection and is logged. | ||
| 404 | func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder)) error { | 421 | func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder)) error { | ||
| 422 | + | return c.callOn(tg.Dest, tg.Path, itemIface, member, sig, args, nil) | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | // callOn is call for any interface. reply, if set, reads the body of a | |||
| 426 | + | // successful reply; it must not keep what it reads. | |||
| 427 | + | func (c *control) callOn(dest, path, iface, member, sig string, args func(*zerobus.Encoder), reply func(*zerobus.Message)) error { | |||
| 405 | conn, err := c.conn2() | 428 | conn, err := c.conn2() | ||
| 406 | if err != nil { | 429 | if err != nil { | ||
| 407 | if !errors.Is(err, zerobus.ErrClosed) { | 430 | if !errors.Is(err, zerobus.ErrClosed) { | ||
| @@ -409,12 +432,17 @@ func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder | |||||
| 409 | } | 432 | } | ||
| 410 | return err | 433 | return err | ||
| 411 | } | 434 | } | ||
| 412 | - | args(conn.NewCall(tg.Dest, tg.Path, itemIface, member, sig)) | 435 | + | args(conn.NewCall(dest, path, iface, member, sig)) |
| 413 | timer := time.AfterFunc(callTimeout, func() { _ = conn.Close() }) | 436 | timer := time.AfterFunc(callTimeout, func() { _ = conn.Close() }) | ||
| 414 | - | _, err = conn.Call() | 437 | + | m, err := conn.Call() |
| 438 | + | if err == nil && reply != nil { | |||
| 439 | + | reply(m) | |||
| 440 | + | } | |||
| 415 | expired := !timer.Stop() | 441 | expired := !timer.Stop() | ||
| 416 | - | var reply *zerobus.Error | 442 | + | var refused *zerobus.Error |
| 417 | - | if errors.As(err, &reply) && !expired { | 443 | + | if (errors.As(err, &refused) || errors.Is(err, zerobus.ErrInvalid)) && !expired { |
| 444 | + | // An error reply, or a name or path the library would not send: | |||
| 445 | + | // the connection is fine. | |||
| 418 | c.log.Debug("tray: the application refused", "method", member, "err", err) | 446 | c.log.Debug("tray: the application refused", "method", member, "err", err) | ||
| 419 | return err | 447 | return err | ||
| 420 | } | 448 | } | ||
| @@ -427,8 +455,103 @@ func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder | |||||
| 427 | stopping := c.done | 455 | stopping := c.done | ||
| 428 | c.mu.Unlock() | 456 | c.mu.Unlock() | ||
| 429 | if !stopping { | 457 | if !stopping { | ||
| 430 | - | c.log.Warn("tray: application did not answer", "method", member, "dest", tg.Dest, "err", err) | 458 | + | c.log.Warn("tray: application did not answer", "method", member, "dest", dest, "err", err) |
| 431 | } | 459 | } | ||
| 432 | } | 460 | } | ||
| 433 | return err | 461 | return err | ||
| 434 | } | 462 | } | ||
| 463 | + | ||||
| 464 | + | // transportFailed reports whether err means the call did not complete, as | |||
| 465 | + | // opposed to the application answering with an error. | |||
| 466 | + | func transportFailed(err error) bool { | |||
| 467 | + | var refused *zerobus.Error | |||
| 468 | + | return err != nil && !errors.As(err, &refused) && !errors.Is(err, zerobus.ErrInvalid) | |||
| 469 | + | } | |||
| 470 | + | ||||
| 471 | + | // openMenu shows the application's menu for the press numbered token: its | |||
| 472 | + | // dbusmenu when it has one, else it asks the application to show its own. | |||
| 473 | + | func (c *control) openMenu(tg Target, token uint32) { | |||
| 474 | + | if tg.Menu == "" || tg.Menu == noMenuPath { | |||
| 475 | + | c.contextMenu(tg) | |||
| 476 | + | return | |||
| 477 | + | } | |||
| 478 | + | // Ignore refusals: many applications do not implement these. | |||
| 479 | + | err := c.callOn(tg.Dest, tg.Menu, menuIface, "AboutToShow", "i", func(e *zerobus.Encoder) { e.Int32(0) }, nil) | |||
| 480 | + | if transportFailed(err) { | |||
| 481 | + | return | |||
| 482 | + | } | |||
| 483 | + | err = c.menuEvent(tg.Dest, tg.Menu, 0, "opened") | |||
| 484 | + | if transportFailed(err) { | |||
| 485 | + | return | |||
| 486 | + | } | |||
| 487 | + | var items []menuItem | |||
| 488 | + | var ids []int32 | |||
| 489 | + | var decodeErr error | |||
| 490 | + | err = c.callOn(tg.Dest, tg.Menu, menuIface, "GetLayout", "iias", func(e *zerobus.Encoder) { | |||
| 491 | + | e.Int32(0) | |||
| 492 | + | e.Int32(-1) | |||
| 493 | + | a := e.BeginArray('s') | |||
| 494 | + | e.EndArray(a) | |||
| 495 | + | }, func(m *zerobus.Message) { | |||
| 496 | + | if m.Signature != "u"+layoutSig { | |||
| 497 | + | decodeErr = errors.New("unexpected layout signature " + m.Signature) | |||
| 498 | + | return | |||
| 499 | + | } | |||
| 500 | + | items, ids, decodeErr = decodeLayout(m.Body()) | |||
| 501 | + | }) | |||
| 502 | + | if err != nil { | |||
| 503 | + | return | |||
| 504 | + | } | |||
| 505 | + | if decodeErr != nil { | |||
| 506 | + | c.log.Warn("tray: unreadable menu", "dest", tg.Dest, "err", decodeErr) | |||
| 507 | + | return | |||
| 508 | + | } | |||
| 509 | + | if len(items) == 0 { | |||
| 510 | + | c.log.Debug("tray: the menu is empty", "dest", tg.Dest) | |||
| 511 | + | return | |||
| 512 | + | } | |||
| 513 | + | line, ids, encErr := encodeMenu(menuLine{Type: "menu", Col: tg.Start, Width: tg.Width, Click: token, Items: items}) | |||
| 514 | + | if encErr != nil { | |||
| 515 | + | c.log.Warn("tray: cannot send the menu", "dest", tg.Dest, "err", encErr) | |||
| 516 | + | return | |||
| 517 | + | } | |||
| 518 | + | c.menu = &lastMenu{token: token, dest: tg.Dest, path: tg.Menu, ids: ids} | |||
| 519 | + | c.writeLine(line) | |||
| 520 | + | } | |||
| 521 | + | ||||
| 522 | + | // menuEvent sends a dbusmenu Event with no data. | |||
| 523 | + | func (c *control) menuEvent(dest, path string, id int32, event string) error { | |||
| 524 | + | return c.callOn(dest, path, menuIface, "Event", "isvu", func(e *zerobus.Encoder) { | |||
| 525 | + | e.Int32(id) | |||
| 526 | + | e.Str(event) | |||
| 527 | + | e.Variant("i") | |||
| 528 | + | e.Int32(0) | |||
| 529 | + | e.Uint32(0) | |||
| 530 | + | }, nil) | |||
| 531 | + | } | |||
| 532 | + | ||||
| 533 | + | // menuActivate carries out a choice of the bar: only for the menu it was last | |||
| 534 | + | // given, and only for an item that menu holds. | |||
| 535 | + | func (c *control) menuActivate(token uint32, id int32) { | |||
| 536 | + | m := c.menu | |||
| 537 | + | if m == nil || m.token != token { | |||
| 538 | + | c.log.Debug("tray: ignoring a choice for an unknown menu", "token", token) | |||
| 539 | + | return | |||
| 540 | + | } | |||
| 541 | + | if _, found := slices.BinarySearch(m.ids, id); !found { | |||
| 542 | + | c.log.Debug("tray: ignoring a choice that is not in the menu", "token", token, "id", id) | |||
| 543 | + | return | |||
| 544 | + | } | |||
| 545 | + | c.menu = nil | |||
| 546 | + | c.menuEvent(m.dest, m.path, id, "clicked") | |||
| 547 | + | } | |||
| 548 | + | ||||
| 549 | + | // menuClosed tells the application its menu is closed. | |||
| 550 | + | func (c *control) menuClosed(token uint32) { | |||
| 551 | + | m := c.menu | |||
| 552 | + | if m == nil || m.token != token { | |||
| 553 | + | return | |||
| 554 | + | } | |||
| 555 | + | c.menu = nil | |||
| 556 | + | c.menuEvent(m.dest, m.path, 0, "closed") | |||
| 557 | + | } | |||
A internal/tray/dbusmenu.go +217 -0
| @@ -0,0 +1,217 @@ | |||
| 1 | + | package tray | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "errors" | |
| 6 | + | "slices" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zerobus" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | menuIface = "com.canonical.dbusmenu" | |
| 14 | + | ||
| 15 | + | // The limits of the menu the bar accepts (module.ParseControl). | |
| 16 | + | maxMenuDepth = 8 | |
| 17 | + | maxMenuItems = 512 | |
| 18 | + | maxMenuLabel = 256 | |
| 19 | + | // maxMenuLine keeps the control line under the bar's 64 KiB, with room | |
| 20 | + | // for the fields around the items. | |
| 21 | + | maxMenuLine = 60 << 10 | |
| 22 | + | ||
| 23 | + | layoutSig = "(ia{sv}av)" | |
| 24 | + | ||
| 25 | + | // noMenuPath is what some toolkits put in Menu to say "no menu". | |
| 26 | + | noMenuPath = "/NO_DBUSMENU" | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | // menuItem is one entry of the menu sent to the bar. | |
| 30 | + | type menuItem struct { | |
| 31 | + | ID int32 `json:"id"` | |
| 32 | + | Label string `json:"label"` | |
| 33 | + | Kind string `json:"kind"` | |
| 34 | + | Enabled bool `json:"enabled"` | |
| 35 | + | Checked bool `json:"checked"` | |
| 36 | + | Items []menuItem `json:"items,omitempty"` | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // menuLine is the control line that opens a menu. | |
| 40 | + | type menuLine struct { | |
| 41 | + | Type string `json:"type"` | |
| 42 | + | Col int `json:"col"` | |
| 43 | + | Width int `json:"width"` | |
| 44 | + | Click uint32 `json:"click"` | |
| 45 | + | Items []menuItem `json:"items"` | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // lastMenu is the menu the bar was last given: what a menu-activate or | |
| 49 | + | // menu-closed line may refer to. | |
| 50 | + | type lastMenu struct { | |
| 51 | + | token uint32 | |
| 52 | + | dest string | |
| 53 | + | path string | |
| 54 | + | ids []int32 // the ids the bar can choose from, sorted | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // layoutDecoder reads the reply of GetLayout. | |
| 58 | + | type layoutDecoder struct { | |
| 59 | + | r *zerobus.Reader | |
| 60 | + | count int | |
| 61 | + | ids []int32 | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // decodeLayout reads a GetLayout reply body, "u(ia{sv}av)": the top level | |
| 65 | + | // items, and the ids among them. Invisible items are dropped; the tree is | |
| 66 | + | // cut at maxMenuDepth levels and maxMenuItems items. Children whose signature | |
| 67 | + | // is not (ia{sv}av) are skipped. | |
| 68 | + | func decodeLayout(r *zerobus.Reader) (items []menuItem, ids []int32, err error) { | |
| 69 | + | r.Uint32() // revision | |
| 70 | + | d := &layoutDecoder{r: r} | |
| 71 | + | root, _ := d.node(0) | |
| 72 | + | if err = r.Err(); err != nil { | |
| 73 | + | return nil, nil, err | |
| 74 | + | } | |
| 75 | + | slices.Sort(d.ids) | |
| 76 | + | return root.Items, d.ids, nil | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // node reads one (ia{sv}av) struct at the given depth (the root is 0). It | |
| 80 | + | // reports false for an item that is not kept: invisible, or over a limit. | |
| 81 | + | func (d *layoutDecoder) node(depth int) (menuItem, bool) { | |
| 82 | + | r := d.r | |
| 83 | + | r.Struct() | |
| 84 | + | it := menuItem{ID: r.Int32(), Kind: "normal", Enabled: true} | |
| 85 | + | visible := true | |
| 86 | + | toggle := "" | |
| 87 | + | checked := false | |
| 88 | + | end := r.Array('{') | |
| 89 | + | for r.More(end) { | |
| 90 | + | r.Struct() | |
| 91 | + | key := r.Str() | |
| 92 | + | sig := r.Variant() | |
| 93 | + | switch { | |
| 94 | + | case key == "label" && sig == "s": | |
| 95 | + | it.Label = cleanLabel(r.Str()) | |
| 96 | + | case key == "enabled" && sig == "b": | |
| 97 | + | it.Enabled = r.Bool() | |
| 98 | + | case key == "visible" && sig == "b": | |
| 99 | + | visible = r.Bool() | |
| 100 | + | case key == "type" && sig == "s": | |
| 101 | + | if r.Str() == "separator" { | |
| 102 | + | it.Kind = "separator" | |
| 103 | + | } | |
| 104 | + | case key == "toggle-type" && sig == "s": | |
| 105 | + | toggle = r.Str() | |
| 106 | + | case key == "toggle-state" && sig == "i": | |
| 107 | + | checked = r.Int32() == 1 | |
| 108 | + | default: | |
| 109 | + | r.Skip(sig) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | if it.Kind != "separator" { | |
| 113 | + | switch toggle { | |
| 114 | + | case "checkmark": | |
| 115 | + | it.Kind = "check" | |
| 116 | + | case "radio": | |
| 117 | + | it.Kind = "radio" | |
| 118 | + | } | |
| 119 | + | it.Checked = checked && it.Kind != "normal" | |
| 120 | + | } | |
| 121 | + | kids := r.Array('v') | |
| 122 | + | keep := depth == 0 || (visible && it.ID >= 0 && d.count < maxMenuItems) | |
| 123 | + | if depth > 0 && keep { | |
| 124 | + | d.count++ | |
| 125 | + | d.ids = append(d.ids, it.ID) | |
| 126 | + | } | |
| 127 | + | if !keep || depth >= maxMenuDepth { | |
| 128 | + | r.SkipTo(kids) | |
| 129 | + | return it, keep | |
| 130 | + | } | |
| 131 | + | for r.More(kids) { | |
| 132 | + | sig := r.Variant() | |
| 133 | + | if sig != layoutSig { | |
| 134 | + | r.Skip(sig) | |
| 135 | + | continue | |
| 136 | + | } | |
| 137 | + | if child, ok := d.node(depth + 1); ok { | |
| 138 | + | it.Items = append(it.Items, child) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | return it, true | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // cleanLabel strips the mnemonic markers of a dbusmenu label: "_x" is "x" and | |
| 145 | + | // "__" is "_". The control characters are the bar's to remove. | |
| 146 | + | func cleanLabel(s string) string { | |
| 147 | + | if !strings.Contains(s, "_") { | |
| 148 | + | return cutRunes(s, maxMenuLabel) | |
| 149 | + | } | |
| 150 | + | var b strings.Builder | |
| 151 | + | for i := 0; i < len(s); i++ { | |
| 152 | + | if s[i] != '_' { | |
| 153 | + | b.WriteByte(s[i]) | |
| 154 | + | continue | |
| 155 | + | } | |
| 156 | + | if i+1 < len(s) { | |
| 157 | + | i++ | |
| 158 | + | b.WriteByte(s[i]) // "_x" and "__" both keep the second byte | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | return cutRunes(b.String(), maxMenuLabel) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // encodeMenu builds the control line for a menu. When the JSON would exceed | |
| 165 | + | // the bar's limit, items are dropped from the end of the menu (in reading | |
| 166 | + | // order) until it fits. | |
| 167 | + | func encodeMenu(l menuLine) ([]byte, []int32, error) { | |
| 168 | + | total := countItems(l.Items) | |
| 169 | + | for { | |
| 170 | + | b, err := json.Marshal(l) | |
| 171 | + | if err != nil { | |
| 172 | + | return nil, nil, err | |
| 173 | + | } | |
| 174 | + | if len(b) <= maxMenuLine { | |
| 175 | + | var ids []int32 | |
| 176 | + | collectIDs(l.Items, &ids) | |
| 177 | + | slices.Sort(ids) | |
| 178 | + | return b, ids, nil | |
| 179 | + | } | |
| 180 | + | if total <= 1 { | |
| 181 | + | return nil, nil, errors.New("tray: the menu is too large") | |
| 182 | + | } | |
| 183 | + | total = total * 3 / 4 | |
| 184 | + | keep := total | |
| 185 | + | l.Items = keepFirst(l.Items, &keep) | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func countItems(items []menuItem) int { | |
| 190 | + | n := len(items) | |
| 191 | + | for i := range items { | |
| 192 | + | n += countItems(items[i].Items) | |
| 193 | + | } | |
| 194 | + | return n | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func collectIDs(items []menuItem, ids *[]int32) { | |
| 198 | + | for i := range items { | |
| 199 | + | *ids = append(*ids, items[i].ID) | |
| 200 | + | collectIDs(items[i].Items, ids) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // keepFirst keeps the first *n items in reading order, parents before their | |
| 205 | + | // children, and returns the trimmed list. | |
| 206 | + | func keepFirst(items []menuItem, n *int) []menuItem { | |
| 207 | + | var out []menuItem | |
| 208 | + | for _, it := range items { | |
| 209 | + | if *n <= 0 { | |
| 210 | + | break | |
| 211 | + | } | |
| 212 | + | *n-- | |
| 213 | + | it.Items = keepFirst(it.Items, n) | |
| 214 | + | out = append(out, it) | |
| 215 | + | } | |
| 216 | + | return out | |
| 217 | + | } | |
| @@ -0,0 +1,217 @@ | |||||
| 1 | + | package tray | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "errors" | |||
| 6 | + | "slices" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zerobus" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const ( | |||
| 13 | + | menuIface = "com.canonical.dbusmenu" | |||
| 14 | + | ||||
| 15 | + | // The limits of the menu the bar accepts (module.ParseControl). | |||
| 16 | + | maxMenuDepth = 8 | |||
| 17 | + | maxMenuItems = 512 | |||
| 18 | + | maxMenuLabel = 256 | |||
| 19 | + | // maxMenuLine keeps the control line under the bar's 64 KiB, with room | |||
| 20 | + | // for the fields around the items. | |||
| 21 | + | maxMenuLine = 60 << 10 | |||
| 22 | + | ||||
| 23 | + | layoutSig = "(ia{sv}av)" | |||
| 24 | + | ||||
| 25 | + | // noMenuPath is what some toolkits put in Menu to say "no menu". | |||
| 26 | + | noMenuPath = "/NO_DBUSMENU" | |||
| 27 | + | ) | |||
| 28 | + | ||||
| 29 | + | // menuItem is one entry of the menu sent to the bar. | |||
| 30 | + | type menuItem struct { | |||
| 31 | + | ID int32 `json:"id"` | |||
| 32 | + | Label string `json:"label"` | |||
| 33 | + | Kind string `json:"kind"` | |||
| 34 | + | Enabled bool `json:"enabled"` | |||
| 35 | + | Checked bool `json:"checked"` | |||
| 36 | + | Items []menuItem `json:"items,omitempty"` | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // menuLine is the control line that opens a menu. | |||
| 40 | + | type menuLine struct { | |||
| 41 | + | Type string `json:"type"` | |||
| 42 | + | Col int `json:"col"` | |||
| 43 | + | Width int `json:"width"` | |||
| 44 | + | Click uint32 `json:"click"` | |||
| 45 | + | Items []menuItem `json:"items"` | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // lastMenu is the menu the bar was last given: what a menu-activate or | |||
| 49 | + | // menu-closed line may refer to. | |||
| 50 | + | type lastMenu struct { | |||
| 51 | + | token uint32 | |||
| 52 | + | dest string | |||
| 53 | + | path string | |||
| 54 | + | ids []int32 // the ids the bar can choose from, sorted | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // layoutDecoder reads the reply of GetLayout. | |||
| 58 | + | type layoutDecoder struct { | |||
| 59 | + | r *zerobus.Reader | |||
| 60 | + | count int | |||
| 61 | + | ids []int32 | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // decodeLayout reads a GetLayout reply body, "u(ia{sv}av)": the top level | |||
| 65 | + | // items, and the ids among them. Invisible items are dropped; the tree is | |||
| 66 | + | // cut at maxMenuDepth levels and maxMenuItems items. Children whose signature | |||
| 67 | + | // is not (ia{sv}av) are skipped. | |||
| 68 | + | func decodeLayout(r *zerobus.Reader) (items []menuItem, ids []int32, err error) { | |||
| 69 | + | r.Uint32() // revision | |||
| 70 | + | d := &layoutDecoder{r: r} | |||
| 71 | + | root, _ := d.node(0) | |||
| 72 | + | if err = r.Err(); err != nil { | |||
| 73 | + | return nil, nil, err | |||
| 74 | + | } | |||
| 75 | + | slices.Sort(d.ids) | |||
| 76 | + | return root.Items, d.ids, nil | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // node reads one (ia{sv}av) struct at the given depth (the root is 0). It | |||
| 80 | + | // reports false for an item that is not kept: invisible, or over a limit. | |||
| 81 | + | func (d *layoutDecoder) node(depth int) (menuItem, bool) { | |||
| 82 | + | r := d.r | |||
| 83 | + | r.Struct() | |||
| 84 | + | it := menuItem{ID: r.Int32(), Kind: "normal", Enabled: true} | |||
| 85 | + | visible := true | |||
| 86 | + | toggle := "" | |||
| 87 | + | checked := false | |||
| 88 | + | end := r.Array('{') | |||
| 89 | + | for r.More(end) { | |||
| 90 | + | r.Struct() | |||
| 91 | + | key := r.Str() | |||
| 92 | + | sig := r.Variant() | |||
| 93 | + | switch { | |||
| 94 | + | case key == "label" && sig == "s": | |||
| 95 | + | it.Label = cleanLabel(r.Str()) | |||
| 96 | + | case key == "enabled" && sig == "b": | |||
| 97 | + | it.Enabled = r.Bool() | |||
| 98 | + | case key == "visible" && sig == "b": | |||
| 99 | + | visible = r.Bool() | |||
| 100 | + | case key == "type" && sig == "s": | |||
| 101 | + | if r.Str() == "separator" { | |||
| 102 | + | it.Kind = "separator" | |||
| 103 | + | } | |||
| 104 | + | case key == "toggle-type" && sig == "s": | |||
| 105 | + | toggle = r.Str() | |||
| 106 | + | case key == "toggle-state" && sig == "i": | |||
| 107 | + | checked = r.Int32() == 1 | |||
| 108 | + | default: | |||
| 109 | + | r.Skip(sig) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | if it.Kind != "separator" { | |||
| 113 | + | switch toggle { | |||
| 114 | + | case "checkmark": | |||
| 115 | + | it.Kind = "check" | |||
| 116 | + | case "radio": | |||
| 117 | + | it.Kind = "radio" | |||
| 118 | + | } | |||
| 119 | + | it.Checked = checked && it.Kind != "normal" | |||
| 120 | + | } | |||
| 121 | + | kids := r.Array('v') | |||
| 122 | + | keep := depth == 0 || (visible && it.ID >= 0 && d.count < maxMenuItems) | |||
| 123 | + | if depth > 0 && keep { | |||
| 124 | + | d.count++ | |||
| 125 | + | d.ids = append(d.ids, it.ID) | |||
| 126 | + | } | |||
| 127 | + | if !keep || depth >= maxMenuDepth { | |||
| 128 | + | r.SkipTo(kids) | |||
| 129 | + | return it, keep | |||
| 130 | + | } | |||
| 131 | + | for r.More(kids) { | |||
| 132 | + | sig := r.Variant() | |||
| 133 | + | if sig != layoutSig { | |||
| 134 | + | r.Skip(sig) | |||
| 135 | + | continue | |||
| 136 | + | } | |||
| 137 | + | if child, ok := d.node(depth + 1); ok { | |||
| 138 | + | it.Items = append(it.Items, child) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | return it, true | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // cleanLabel strips the mnemonic markers of a dbusmenu label: "_x" is "x" and | |||
| 145 | + | // "__" is "_". The control characters are the bar's to remove. | |||
| 146 | + | func cleanLabel(s string) string { | |||
| 147 | + | if !strings.Contains(s, "_") { | |||
| 148 | + | return cutRunes(s, maxMenuLabel) | |||
| 149 | + | } | |||
| 150 | + | var b strings.Builder | |||
| 151 | + | for i := 0; i < len(s); i++ { | |||
| 152 | + | if s[i] != '_' { | |||
| 153 | + | b.WriteByte(s[i]) | |||
| 154 | + | continue | |||
| 155 | + | } | |||
| 156 | + | if i+1 < len(s) { | |||
| 157 | + | i++ | |||
| 158 | + | b.WriteByte(s[i]) // "_x" and "__" both keep the second byte | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | return cutRunes(b.String(), maxMenuLabel) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // encodeMenu builds the control line for a menu. When the JSON would exceed | |||
| 165 | + | // the bar's limit, items are dropped from the end of the menu (in reading | |||
| 166 | + | // order) until it fits. | |||
| 167 | + | func encodeMenu(l menuLine) ([]byte, []int32, error) { | |||
| 168 | + | total := countItems(l.Items) | |||
| 169 | + | for { | |||
| 170 | + | b, err := json.Marshal(l) | |||
| 171 | + | if err != nil { | |||
| 172 | + | return nil, nil, err | |||
| 173 | + | } | |||
| 174 | + | if len(b) <= maxMenuLine { | |||
| 175 | + | var ids []int32 | |||
| 176 | + | collectIDs(l.Items, &ids) | |||
| 177 | + | slices.Sort(ids) | |||
| 178 | + | return b, ids, nil | |||
| 179 | + | } | |||
| 180 | + | if total <= 1 { | |||
| 181 | + | return nil, nil, errors.New("tray: the menu is too large") | |||
| 182 | + | } | |||
| 183 | + | total = total * 3 / 4 | |||
| 184 | + | keep := total | |||
| 185 | + | l.Items = keepFirst(l.Items, &keep) | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func countItems(items []menuItem) int { | |||
| 190 | + | n := len(items) | |||
| 191 | + | for i := range items { | |||
| 192 | + | n += countItems(items[i].Items) | |||
| 193 | + | } | |||
| 194 | + | return n | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func collectIDs(items []menuItem, ids *[]int32) { | |||
| 198 | + | for i := range items { | |||
| 199 | + | *ids = append(*ids, items[i].ID) | |||
| 200 | + | collectIDs(items[i].Items, ids) | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // keepFirst keeps the first *n items in reading order, parents before their | |||
| 205 | + | // children, and returns the trimmed list. | |||
| 206 | + | func keepFirst(items []menuItem, n *int) []menuItem { | |||
| 207 | + | var out []menuItem | |||
| 208 | + | for _, it := range items { | |||
| 209 | + | if *n <= 0 { | |||
| 210 | + | break | |||
| 211 | + | } | |||
| 212 | + | *n-- | |||
| 213 | + | it.Items = keepFirst(it.Items, n) | |||
| 214 | + | out = append(out, it) | |||
| 215 | + | } | |||
| 216 | + | return out | |||
| 217 | + | } | |||
A internal/tray/dbusmenu_test.go +327 -0
| @@ -0,0 +1,327 @@ | |||
| 1 | + | package tray | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zerobus" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/neferbar/internal/module" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // tprop is one property of a test menu node. | |
| 15 | + | type tprop struct { | |
| 16 | + | k string | |
| 17 | + | v any // string, bool or int32 | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // tnode is a node of the layout a fake application serves. | |
| 21 | + | type tnode struct { | |
| 22 | + | id int32 | |
| 23 | + | props []tprop | |
| 24 | + | kids []tnode | |
| 25 | + | // junk is written as an extra child with a signature that is not a layout node. | |
| 26 | + | junk bool | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (n *tnode) encode(e *zerobus.Encoder) { | |
| 30 | + | e.Struct() | |
| 31 | + | e.Int32(n.id) | |
| 32 | + | a := e.BeginArray('{') | |
| 33 | + | for _, p := range n.props { | |
| 34 | + | e.Struct() | |
| 35 | + | e.Str(p.k) | |
| 36 | + | switch v := p.v.(type) { | |
| 37 | + | case string: | |
| 38 | + | e.Variant("s") | |
| 39 | + | e.Str(v) | |
| 40 | + | case bool: | |
| 41 | + | e.Variant("b") | |
| 42 | + | e.Bool(v) | |
| 43 | + | case int32: | |
| 44 | + | e.Variant("i") | |
| 45 | + | e.Int32(v) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | e.EndArray(a) | |
| 49 | + | kids := e.BeginArray('v') | |
| 50 | + | if n.junk { | |
| 51 | + | e.Variant("s") | |
| 52 | + | e.Str("not a node") | |
| 53 | + | } | |
| 54 | + | for i := range n.kids { | |
| 55 | + | e.Variant(layoutSig) | |
| 56 | + | n.kids[i].encode(e) | |
| 57 | + | } | |
| 58 | + | e.EndArray(kids) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // menuCall answers a dbusmenu call and records it on mcalls. | |
| 62 | + | func (f *fakeItem) menuCall(m *zerobus.Message) bool { | |
| 63 | + | r := m.Body() | |
| 64 | + | switch m.Member { | |
| 65 | + | case "AboutToShow": | |
| 66 | + | f.mcalls <- fmt.Sprintf("AboutToShow(%d)", r.Int32()) | |
| 67 | + | f.c.NewReply(m, "b").Bool(false) | |
| 68 | + | case "Event": | |
| 69 | + | id, name := r.Int32(), r.Str() | |
| 70 | + | r.Skip(r.Variant()) | |
| 71 | + | f.mcalls <- fmt.Sprintf("Event(%d,%s)", id, name) | |
| 72 | + | f.c.NewReply(m, "") | |
| 73 | + | case "GetLayout": | |
| 74 | + | f.mcalls <- "GetLayout" | |
| 75 | + | if f.menu == nil { | |
| 76 | + | f.c.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("no menu") | |
| 77 | + | break | |
| 78 | + | } | |
| 79 | + | e := f.c.NewReply(m, "u"+layoutSig) | |
| 80 | + | e.Uint32(1) | |
| 81 | + | f.menu.encode(e) | |
| 82 | + | default: | |
| 83 | + | return true | |
| 84 | + | } | |
| 85 | + | _, err := f.c.Send() | |
| 86 | + | return err == nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func expectMenuCall(t *testing.T, f *fakeItem, want string) { | |
| 90 | + | t.Helper() | |
| 91 | + | select { | |
| 92 | + | case got := <-f.mcalls: | |
| 93 | + | if got != want { | |
| 94 | + | t.Fatalf("menu got %s, want %s", got, want) | |
| 95 | + | } | |
| 96 | + | case <-time.After(5 * time.Second): | |
| 97 | + | t.Fatalf("menu never got %s", want) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func expectNoMenuCall(t *testing.T, f *fakeItem) { | |
| 102 | + | t.Helper() | |
| 103 | + | select { | |
| 104 | + | case got := <-f.mcalls: | |
| 105 | + | t.Fatalf("unexpected menu call %s", got) | |
| 106 | + | case <-time.After(150 * time.Millisecond): | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func sampleLayout() *tnode { | |
| 111 | + | return &tnode{id: 0, kids: []tnode{ | |
| 112 | + | {id: 1, props: []tprop{{"label", "_Open"}}}, | |
| 113 | + | {id: 2, props: []tprop{{"type", "separator"}}}, | |
| 114 | + | {id: 3, props: []tprop{{"label", "Mu__te"}, {"toggle-type", "checkmark"}, {"toggle-state", int32(1)}}}, | |
| 115 | + | {id: 4, props: []tprop{{"label", "Settings"}, {"children-display", "submenu"}}, kids: []tnode{ | |
| 116 | + | {id: 5, props: []tprop{{"label", "Advanced"}}}, | |
| 117 | + | }}, | |
| 118 | + | {id: 6, props: []tprop{{"label", "Quit"}, {"enabled", false}}}, | |
| 119 | + | {id: 7, props: []tprop{{"label", "Hidden"}, {"visible", false}}, kids: []tnode{{id: 70, props: []tprop{{"label", "Under hidden"}}}}}, | |
| 120 | + | {id: 8, props: []tprop{{"label", "Radio"}, {"toggle-type", "radio"}, {"toggle-state", int32(0)}}}, | |
| 121 | + | }, junk: true} | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // menuReady gives steam a dbusmenu and waits until the tray knows it: right | |
| 125 | + | // clicks fall back to ContextMenu until then, and the first menu line proves it. | |
| 126 | + | func menuReady(t *testing.T, steam *fakeItem, in barInput, out *lines, layout *tnode) module.Control { | |
| 127 | + | t.Helper() | |
| 128 | + | steam.run(t, func() { | |
| 129 | + | steam.menu = layout | |
| 130 | + | steam.c.NewSignal(itemPath, itemIface, "NewStatus", "s").Str("Active") | |
| 131 | + | _, _ = steam.c.Send() | |
| 132 | + | }) | |
| 133 | + | deadline := time.Now().Add(5 * time.Second) | |
| 134 | + | for time.Now().Before(deadline) { | |
| 135 | + | in.send(t, "click right 0 9") | |
| 136 | + | wait := time.After(600 * time.Millisecond) | |
| 137 | + | poll: | |
| 138 | + | for { | |
| 139 | + | select { | |
| 140 | + | case s := <-out.ch: | |
| 141 | + | if j, ok := strings.CutPrefix(s, controlPrefix); ok && strings.Contains(j, `"type":"menu"`) { | |
| 142 | + | c, err := module.ParseControl([]byte(strings.TrimSuffix(j, "\x07"))) | |
| 143 | + | if err != nil { | |
| 144 | + | t.Fatalf("the bar would refuse the menu line %q: %v", j, err) | |
| 145 | + | } | |
| 146 | + | // Drain what the successful open recorded. | |
| 147 | + | for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout"} { | |
| 148 | + | expectMenuCall(t, steam, want) | |
| 149 | + | } | |
| 150 | + | return c | |
| 151 | + | } | |
| 152 | + | case <-steam.calls: // ContextMenu: the tray has not seen the menu yet | |
| 153 | + | break poll | |
| 154 | + | case <-wait: | |
| 155 | + | break poll | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | for len(steam.mcalls) > 0 { | |
| 159 | + | <-steam.mcalls | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | t.Fatal("the tray never produced a menu") | |
| 163 | + | return module.Control{} | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestRightClickOpensTheDbusmenu(t *testing.T) { | |
| 167 | + | steam, _, in, out := setup(t) | |
| 168 | + | c := menuReady(t, steam, in, out, sampleLayout()) | |
| 169 | + | ||
| 170 | + | if c.Type != module.ControlMenu || c.Click != 9 || c.Col != 0 || c.Width != 1 { | |
| 171 | + | t.Fatalf("control = %+v", c) | |
| 172 | + | } | |
| 173 | + | type flat struct { | |
| 174 | + | id int32 | |
| 175 | + | label, kind string | |
| 176 | + | enabled, chk bool | |
| 177 | + | kids int | |
| 178 | + | } | |
| 179 | + | var got []flat | |
| 180 | + | for _, it := range c.Items { | |
| 181 | + | got = append(got, flat{it.ID, it.Label, it.Kind, it.Enabled, it.Checked, len(it.Items)}) | |
| 182 | + | } | |
| 183 | + | want := []flat{ | |
| 184 | + | {1, "Open", "normal", true, false, 0}, | |
| 185 | + | {2, "", "separator", true, false, 0}, | |
| 186 | + | {3, "Mute", "check", true, true, 0}, // "__" is a literal underscore: "Mu_te" | |
| 187 | + | {4, "Settings", "normal", true, false, 1}, | |
| 188 | + | {6, "Quit", "normal", false, false, 0}, | |
| 189 | + | {8, "Radio", "radio", true, false, 0}, | |
| 190 | + | } | |
| 191 | + | want[2].label = "Mu_te" | |
| 192 | + | if fmt.Sprint(got) != fmt.Sprint(want) { | |
| 193 | + | t.Fatalf("items = %+v\nwant %+v", got, want) | |
| 194 | + | } | |
| 195 | + | if sub := c.Items[3].Items[0]; sub.ID != 5 || sub.Label != "Advanced" { | |
| 196 | + | t.Fatalf("submenu = %+v", c.Items[3].Items) | |
| 197 | + | } | |
| 198 | + | expectNoCall(t, steam) // no ContextMenu: the bar draws the menu | |
| 199 | + | ||
| 200 | + | // A choice reaches the application, once. | |
| 201 | + | in.send(t, "menu-activate 9 5") | |
| 202 | + | expectMenuCall(t, steam, "Event(5,clicked)") | |
| 203 | + | in.send(t, "menu-activate 9 5", "menu-closed 9") | |
| 204 | + | expectNoMenuCall(t, steam) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func TestMenuClosedAndStaleTokens(t *testing.T) { | |
| 208 | + | steam, _, in, out := setup(t) | |
| 209 | + | menuReady(t, steam, in, out, sampleLayout()) | |
| 210 | + | ||
| 211 | + | in.send(t, "menu-activate 8 1") // another token | |
| 212 | + | in.send(t, "menu-closed 8") | |
| 213 | + | in.send(t, "menu-activate 9 99") // an id the menu does not hold | |
| 214 | + | in.send(t, "menu-activate 9 7") // an invisible item | |
| 215 | + | in.send(t, "menu-activate 9 70") // a child of an invisible item | |
| 216 | + | expectNoMenuCall(t, steam) | |
| 217 | + | ||
| 218 | + | in.send(t, "menu-closed 9") | |
| 219 | + | expectMenuCall(t, steam, "Event(0,closed)") | |
| 220 | + | in.send(t, "menu-closed 9", "menu-activate 9 1") // forgotten after closing | |
| 221 | + | expectNoMenuCall(t, steam) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func TestNewerMenuReplacesTheOlder(t *testing.T) { | |
| 225 | + | steam, _, in, out := setup(t) | |
| 226 | + | menuReady(t, steam, in, out, sampleLayout()) | |
| 227 | + | in.send(t, "click right 0 10") | |
| 228 | + | out.waitFor(t, "the second menu", func(s string) bool { return strings.Contains(s, `"click":10`) }) | |
| 229 | + | for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout"} { | |
| 230 | + | expectMenuCall(t, steam, want) | |
| 231 | + | } | |
| 232 | + | in.send(t, "menu-activate 9 1") // the first menu is gone | |
| 233 | + | expectNoMenuCall(t, steam) | |
| 234 | + | in.send(t, "menu-activate 10 1") | |
| 235 | + | expectMenuCall(t, steam, "Event(1,clicked)") | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func TestLeftClickOnAMenuOnlyItemOpensTheMenu(t *testing.T) { | |
| 239 | + | steam, _, in, out := setup(t) | |
| 240 | + | menuReady(t, steam, in, out, sampleLayout()) | |
| 241 | + | // ItemIsMenu is not served by the fake, so the left click calls Activate, | |
| 242 | + | // which it refuses: the fallback opens the menu. | |
| 243 | + | steam.run(t, func() { steam.unknown = "Activate" }) | |
| 244 | + | in.send(t, "click left 0 12") | |
| 245 | + | expectCall(t, steam, "Activate(0,0)") | |
| 246 | + | out.waitFor(t, "the menu after the fallback", func(s string) bool { return strings.Contains(s, `"click":12`) }) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func TestMenuLayoutLimitsAndDecoding(t *testing.T) { | |
| 250 | + | // Build a deep and wide layout and decode it through the real reply path. | |
| 251 | + | deep := tnode{id: 100, props: []tprop{{"label", "d"}}} | |
| 252 | + | for i := int32(1); i < 12; i++ { | |
| 253 | + | deep = tnode{id: 100 + i, props: []tprop{{"label", "d"}}, kids: []tnode{deep}} | |
| 254 | + | } | |
| 255 | + | root := tnode{id: 0, kids: []tnode{deep}} | |
| 256 | + | for i := int32(0); i < 600; i++ { | |
| 257 | + | root.kids = append(root.kids, tnode{id: 1000 + i, props: []tprop{{"label", strings.Repeat("x", 300)}}}) | |
| 258 | + | } | |
| 259 | + | items, ids := decodeTestLayout(t, &root) | |
| 260 | + | if n := countItems(items); n > maxMenuItems || len(ids) != n { | |
| 261 | + | t.Fatalf("items %d, ids %d, want at most %d and equal", n, len(ids), maxMenuItems) | |
| 262 | + | } | |
| 263 | + | depth := 0 | |
| 264 | + | for l := items; len(l) > 0; l = l[0].Items { | |
| 265 | + | depth++ | |
| 266 | + | } | |
| 267 | + | if depth != maxMenuDepth { | |
| 268 | + | t.Fatalf("depth %d, want %d", depth, maxMenuDepth) | |
| 269 | + | } | |
| 270 | + | if got := len(items[len(items)-1].Label); got != 256 { | |
| 271 | + | t.Fatalf("label of %d bytes, want 256", got) | |
| 272 | + | } | |
| 273 | + | line, _, err := encodeMenu(menuLine{Type: "menu", Width: 1, Click: 1, Items: items}) | |
| 274 | + | if err != nil || len(line) > maxMenuLine { | |
| 275 | + | t.Fatalf("encodeMenu: %d bytes, %v", len(line), err) | |
| 276 | + | } | |
| 277 | + | if _, err = module.ParseControl(line); err != nil { | |
| 278 | + | t.Fatalf("the bar refuses the trimmed menu: %v", err) | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | func TestMenuLabelMnemonics(t *testing.T) { | |
| 283 | + | for in, want := range map[string]string{ | |
| 284 | + | "_Open": "Open", "Mu__te": "Mu_te", "plain": "plain", "": "", "_": "", "a_": "a", "__": "_", "_a_b": "ab", "é_è": "éè", | |
| 285 | + | } { | |
| 286 | + | if got := cleanLabel(in); got != want { | |
| 287 | + | t.Errorf("cleanLabel(%q) = %q, want %q", in, got, want) | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // decodeTestLayout encodes root as a GetLayout reply and decodes it again. | |
| 293 | + | func decodeTestLayout(t *testing.T, root *tnode) ([]menuItem, []int32) { | |
| 294 | + | t.Helper() | |
| 295 | + | addr := privateBus(t) | |
| 296 | + | srv, cli := dial(t, addr), dial(t, addr) | |
| 297 | + | done := make(chan struct{}) | |
| 298 | + | go func() { | |
| 299 | + | defer close(done) | |
| 300 | + | var m *zerobus.Message | |
| 301 | + | for { // the bus first sends signals such as NameAcquired | |
| 302 | + | var err error | |
| 303 | + | if m, err = srv.ReadMessage(); err != nil { | |
| 304 | + | return | |
| 305 | + | } | |
| 306 | + | if m.Type == zerobus.TypeMethodCall { | |
| 307 | + | break | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | e := srv.NewReply(m, "u"+layoutSig) | |
| 311 | + | e.Uint32(1) | |
| 312 | + | root.encode(e) | |
| 313 | + | _, _ = srv.Send() | |
| 314 | + | }() | |
| 315 | + | cli.NewCall(srv.UniqueName(), "/MenuBar", menuIface, "GetLayout", "") | |
| 316 | + | m, err := cli.Call() | |
| 317 | + | if err != nil { | |
| 318 | + | t.Fatal(err) | |
| 319 | + | } | |
| 320 | + | items, ids, err := decodeLayout(m.Body()) | |
| 321 | + | if err != nil { | |
| 322 | + | t.Fatal(err) | |
| 323 | + | } | |
| 324 | + | _ = srv.Close() | |
| 325 | + | <-done | |
| 326 | + | return items, ids | |
| 327 | + | } | |
| @@ -0,0 +1,327 @@ | |||||
| 1 | + | package tray | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zerobus" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/neferbar/internal/module" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // tprop is one property of a test menu node. | |||
| 15 | + | type tprop struct { | |||
| 16 | + | k string | |||
| 17 | + | v any // string, bool or int32 | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // tnode is a node of the layout a fake application serves. | |||
| 21 | + | type tnode struct { | |||
| 22 | + | id int32 | |||
| 23 | + | props []tprop | |||
| 24 | + | kids []tnode | |||
| 25 | + | // junk is written as an extra child with a signature that is not a layout node. | |||
| 26 | + | junk bool | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (n *tnode) encode(e *zerobus.Encoder) { | |||
| 30 | + | e.Struct() | |||
| 31 | + | e.Int32(n.id) | |||
| 32 | + | a := e.BeginArray('{') | |||
| 33 | + | for _, p := range n.props { | |||
| 34 | + | e.Struct() | |||
| 35 | + | e.Str(p.k) | |||
| 36 | + | switch v := p.v.(type) { | |||
| 37 | + | case string: | |||
| 38 | + | e.Variant("s") | |||
| 39 | + | e.Str(v) | |||
| 40 | + | case bool: | |||
| 41 | + | e.Variant("b") | |||
| 42 | + | e.Bool(v) | |||
| 43 | + | case int32: | |||
| 44 | + | e.Variant("i") | |||
| 45 | + | e.Int32(v) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | e.EndArray(a) | |||
| 49 | + | kids := e.BeginArray('v') | |||
| 50 | + | if n.junk { | |||
| 51 | + | e.Variant("s") | |||
| 52 | + | e.Str("not a node") | |||
| 53 | + | } | |||
| 54 | + | for i := range n.kids { | |||
| 55 | + | e.Variant(layoutSig) | |||
| 56 | + | n.kids[i].encode(e) | |||
| 57 | + | } | |||
| 58 | + | e.EndArray(kids) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // menuCall answers a dbusmenu call and records it on mcalls. | |||
| 62 | + | func (f *fakeItem) menuCall(m *zerobus.Message) bool { | |||
| 63 | + | r := m.Body() | |||
| 64 | + | switch m.Member { | |||
| 65 | + | case "AboutToShow": | |||
| 66 | + | f.mcalls <- fmt.Sprintf("AboutToShow(%d)", r.Int32()) | |||
| 67 | + | f.c.NewReply(m, "b").Bool(false) | |||
| 68 | + | case "Event": | |||
| 69 | + | id, name := r.Int32(), r.Str() | |||
| 70 | + | r.Skip(r.Variant()) | |||
| 71 | + | f.mcalls <- fmt.Sprintf("Event(%d,%s)", id, name) | |||
| 72 | + | f.c.NewReply(m, "") | |||
| 73 | + | case "GetLayout": | |||
| 74 | + | f.mcalls <- "GetLayout" | |||
| 75 | + | if f.menu == nil { | |||
| 76 | + | f.c.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("no menu") | |||
| 77 | + | break | |||
| 78 | + | } | |||
| 79 | + | e := f.c.NewReply(m, "u"+layoutSig) | |||
| 80 | + | e.Uint32(1) | |||
| 81 | + | f.menu.encode(e) | |||
| 82 | + | default: | |||
| 83 | + | return true | |||
| 84 | + | } | |||
| 85 | + | _, err := f.c.Send() | |||
| 86 | + | return err == nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func expectMenuCall(t *testing.T, f *fakeItem, want string) { | |||
| 90 | + | t.Helper() | |||
| 91 | + | select { | |||
| 92 | + | case got := <-f.mcalls: | |||
| 93 | + | if got != want { | |||
| 94 | + | t.Fatalf("menu got %s, want %s", got, want) | |||
| 95 | + | } | |||
| 96 | + | case <-time.After(5 * time.Second): | |||
| 97 | + | t.Fatalf("menu never got %s", want) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func expectNoMenuCall(t *testing.T, f *fakeItem) { | |||
| 102 | + | t.Helper() | |||
| 103 | + | select { | |||
| 104 | + | case got := <-f.mcalls: | |||
| 105 | + | t.Fatalf("unexpected menu call %s", got) | |||
| 106 | + | case <-time.After(150 * time.Millisecond): | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func sampleLayout() *tnode { | |||
| 111 | + | return &tnode{id: 0, kids: []tnode{ | |||
| 112 | + | {id: 1, props: []tprop{{"label", "_Open"}}}, | |||
| 113 | + | {id: 2, props: []tprop{{"type", "separator"}}}, | |||
| 114 | + | {id: 3, props: []tprop{{"label", "Mu__te"}, {"toggle-type", "checkmark"}, {"toggle-state", int32(1)}}}, | |||
| 115 | + | {id: 4, props: []tprop{{"label", "Settings"}, {"children-display", "submenu"}}, kids: []tnode{ | |||
| 116 | + | {id: 5, props: []tprop{{"label", "Advanced"}}}, | |||
| 117 | + | }}, | |||
| 118 | + | {id: 6, props: []tprop{{"label", "Quit"}, {"enabled", false}}}, | |||
| 119 | + | {id: 7, props: []tprop{{"label", "Hidden"}, {"visible", false}}, kids: []tnode{{id: 70, props: []tprop{{"label", "Under hidden"}}}}}, | |||
| 120 | + | {id: 8, props: []tprop{{"label", "Radio"}, {"toggle-type", "radio"}, {"toggle-state", int32(0)}}}, | |||
| 121 | + | }, junk: true} | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // menuReady gives steam a dbusmenu and waits until the tray knows it: right | |||
| 125 | + | // clicks fall back to ContextMenu until then, and the first menu line proves it. | |||
| 126 | + | func menuReady(t *testing.T, steam *fakeItem, in barInput, out *lines, layout *tnode) module.Control { | |||
| 127 | + | t.Helper() | |||
| 128 | + | steam.run(t, func() { | |||
| 129 | + | steam.menu = layout | |||
| 130 | + | steam.c.NewSignal(itemPath, itemIface, "NewStatus", "s").Str("Active") | |||
| 131 | + | _, _ = steam.c.Send() | |||
| 132 | + | }) | |||
| 133 | + | deadline := time.Now().Add(5 * time.Second) | |||
| 134 | + | for time.Now().Before(deadline) { | |||
| 135 | + | in.send(t, "click right 0 9") | |||
| 136 | + | wait := time.After(600 * time.Millisecond) | |||
| 137 | + | poll: | |||
| 138 | + | for { | |||
| 139 | + | select { | |||
| 140 | + | case s := <-out.ch: | |||
| 141 | + | if j, ok := strings.CutPrefix(s, controlPrefix); ok && strings.Contains(j, `"type":"menu"`) { | |||
| 142 | + | c, err := module.ParseControl([]byte(strings.TrimSuffix(j, "\x07"))) | |||
| 143 | + | if err != nil { | |||
| 144 | + | t.Fatalf("the bar would refuse the menu line %q: %v", j, err) | |||
| 145 | + | } | |||
| 146 | + | // Drain what the successful open recorded. | |||
| 147 | + | for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout"} { | |||
| 148 | + | expectMenuCall(t, steam, want) | |||
| 149 | + | } | |||
| 150 | + | return c | |||
| 151 | + | } | |||
| 152 | + | case <-steam.calls: // ContextMenu: the tray has not seen the menu yet | |||
| 153 | + | break poll | |||
| 154 | + | case <-wait: | |||
| 155 | + | break poll | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | for len(steam.mcalls) > 0 { | |||
| 159 | + | <-steam.mcalls | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | t.Fatal("the tray never produced a menu") | |||
| 163 | + | return module.Control{} | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func TestRightClickOpensTheDbusmenu(t *testing.T) { | |||
| 167 | + | steam, _, in, out := setup(t) | |||
| 168 | + | c := menuReady(t, steam, in, out, sampleLayout()) | |||
| 169 | + | ||||
| 170 | + | if c.Type != module.ControlMenu || c.Click != 9 || c.Col != 0 || c.Width != 1 { | |||
| 171 | + | t.Fatalf("control = %+v", c) | |||
| 172 | + | } | |||
| 173 | + | type flat struct { | |||
| 174 | + | id int32 | |||
| 175 | + | label, kind string | |||
| 176 | + | enabled, chk bool | |||
| 177 | + | kids int | |||
| 178 | + | } | |||
| 179 | + | var got []flat | |||
| 180 | + | for _, it := range c.Items { | |||
| 181 | + | got = append(got, flat{it.ID, it.Label, it.Kind, it.Enabled, it.Checked, len(it.Items)}) | |||
| 182 | + | } | |||
| 183 | + | want := []flat{ | |||
| 184 | + | {1, "Open", "normal", true, false, 0}, | |||
| 185 | + | {2, "", "separator", true, false, 0}, | |||
| 186 | + | {3, "Mute", "check", true, true, 0}, // "__" is a literal underscore: "Mu_te" | |||
| 187 | + | {4, "Settings", "normal", true, false, 1}, | |||
| 188 | + | {6, "Quit", "normal", false, false, 0}, | |||
| 189 | + | {8, "Radio", "radio", true, false, 0}, | |||
| 190 | + | } | |||
| 191 | + | want[2].label = "Mu_te" | |||
| 192 | + | if fmt.Sprint(got) != fmt.Sprint(want) { | |||
| 193 | + | t.Fatalf("items = %+v\nwant %+v", got, want) | |||
| 194 | + | } | |||
| 195 | + | if sub := c.Items[3].Items[0]; sub.ID != 5 || sub.Label != "Advanced" { | |||
| 196 | + | t.Fatalf("submenu = %+v", c.Items[3].Items) | |||
| 197 | + | } | |||
| 198 | + | expectNoCall(t, steam) // no ContextMenu: the bar draws the menu | |||
| 199 | + | ||||
| 200 | + | // A choice reaches the application, once. | |||
| 201 | + | in.send(t, "menu-activate 9 5") | |||
| 202 | + | expectMenuCall(t, steam, "Event(5,clicked)") | |||
| 203 | + | in.send(t, "menu-activate 9 5", "menu-closed 9") | |||
| 204 | + | expectNoMenuCall(t, steam) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func TestMenuClosedAndStaleTokens(t *testing.T) { | |||
| 208 | + | steam, _, in, out := setup(t) | |||
| 209 | + | menuReady(t, steam, in, out, sampleLayout()) | |||
| 210 | + | ||||
| 211 | + | in.send(t, "menu-activate 8 1") // another token | |||
| 212 | + | in.send(t, "menu-closed 8") | |||
| 213 | + | in.send(t, "menu-activate 9 99") // an id the menu does not hold | |||
| 214 | + | in.send(t, "menu-activate 9 7") // an invisible item | |||
| 215 | + | in.send(t, "menu-activate 9 70") // a child of an invisible item | |||
| 216 | + | expectNoMenuCall(t, steam) | |||
| 217 | + | ||||
| 218 | + | in.send(t, "menu-closed 9") | |||
| 219 | + | expectMenuCall(t, steam, "Event(0,closed)") | |||
| 220 | + | in.send(t, "menu-closed 9", "menu-activate 9 1") // forgotten after closing | |||
| 221 | + | expectNoMenuCall(t, steam) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func TestNewerMenuReplacesTheOlder(t *testing.T) { | |||
| 225 | + | steam, _, in, out := setup(t) | |||
| 226 | + | menuReady(t, steam, in, out, sampleLayout()) | |||
| 227 | + | in.send(t, "click right 0 10") | |||
| 228 | + | out.waitFor(t, "the second menu", func(s string) bool { return strings.Contains(s, `"click":10`) }) | |||
| 229 | + | for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout"} { | |||
| 230 | + | expectMenuCall(t, steam, want) | |||
| 231 | + | } | |||
| 232 | + | in.send(t, "menu-activate 9 1") // the first menu is gone | |||
| 233 | + | expectNoMenuCall(t, steam) | |||
| 234 | + | in.send(t, "menu-activate 10 1") | |||
| 235 | + | expectMenuCall(t, steam, "Event(1,clicked)") | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func TestLeftClickOnAMenuOnlyItemOpensTheMenu(t *testing.T) { | |||
| 239 | + | steam, _, in, out := setup(t) | |||
| 240 | + | menuReady(t, steam, in, out, sampleLayout()) | |||
| 241 | + | // ItemIsMenu is not served by the fake, so the left click calls Activate, | |||
| 242 | + | // which it refuses: the fallback opens the menu. | |||
| 243 | + | steam.run(t, func() { steam.unknown = "Activate" }) | |||
| 244 | + | in.send(t, "click left 0 12") | |||
| 245 | + | expectCall(t, steam, "Activate(0,0)") | |||
| 246 | + | out.waitFor(t, "the menu after the fallback", func(s string) bool { return strings.Contains(s, `"click":12`) }) | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func TestMenuLayoutLimitsAndDecoding(t *testing.T) { | |||
| 250 | + | // Build a deep and wide layout and decode it through the real reply path. | |||
| 251 | + | deep := tnode{id: 100, props: []tprop{{"label", "d"}}} | |||
| 252 | + | for i := int32(1); i < 12; i++ { | |||
| 253 | + | deep = tnode{id: 100 + i, props: []tprop{{"label", "d"}}, kids: []tnode{deep}} | |||
| 254 | + | } | |||
| 255 | + | root := tnode{id: 0, kids: []tnode{deep}} | |||
| 256 | + | for i := int32(0); i < 600; i++ { | |||
| 257 | + | root.kids = append(root.kids, tnode{id: 1000 + i, props: []tprop{{"label", strings.Repeat("x", 300)}}}) | |||
| 258 | + | } | |||
| 259 | + | items, ids := decodeTestLayout(t, &root) | |||
| 260 | + | if n := countItems(items); n > maxMenuItems || len(ids) != n { | |||
| 261 | + | t.Fatalf("items %d, ids %d, want at most %d and equal", n, len(ids), maxMenuItems) | |||
| 262 | + | } | |||
| 263 | + | depth := 0 | |||
| 264 | + | for l := items; len(l) > 0; l = l[0].Items { | |||
| 265 | + | depth++ | |||
| 266 | + | } | |||
| 267 | + | if depth != maxMenuDepth { | |||
| 268 | + | t.Fatalf("depth %d, want %d", depth, maxMenuDepth) | |||
| 269 | + | } | |||
| 270 | + | if got := len(items[len(items)-1].Label); got != 256 { | |||
| 271 | + | t.Fatalf("label of %d bytes, want 256", got) | |||
| 272 | + | } | |||
| 273 | + | line, _, err := encodeMenu(menuLine{Type: "menu", Width: 1, Click: 1, Items: items}) | |||
| 274 | + | if err != nil || len(line) > maxMenuLine { | |||
| 275 | + | t.Fatalf("encodeMenu: %d bytes, %v", len(line), err) | |||
| 276 | + | } | |||
| 277 | + | if _, err = module.ParseControl(line); err != nil { | |||
| 278 | + | t.Fatalf("the bar refuses the trimmed menu: %v", err) | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | func TestMenuLabelMnemonics(t *testing.T) { | |||
| 283 | + | for in, want := range map[string]string{ | |||
| 284 | + | "_Open": "Open", "Mu__te": "Mu_te", "plain": "plain", "": "", "_": "", "a_": "a", "__": "_", "_a_b": "ab", "é_è": "éè", | |||
| 285 | + | } { | |||
| 286 | + | if got := cleanLabel(in); got != want { | |||
| 287 | + | t.Errorf("cleanLabel(%q) = %q, want %q", in, got, want) | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | // decodeTestLayout encodes root as a GetLayout reply and decodes it again. | |||
| 293 | + | func decodeTestLayout(t *testing.T, root *tnode) ([]menuItem, []int32) { | |||
| 294 | + | t.Helper() | |||
| 295 | + | addr := privateBus(t) | |||
| 296 | + | srv, cli := dial(t, addr), dial(t, addr) | |||
| 297 | + | done := make(chan struct{}) | |||
| 298 | + | go func() { | |||
| 299 | + | defer close(done) | |||
| 300 | + | var m *zerobus.Message | |||
| 301 | + | for { // the bus first sends signals such as NameAcquired | |||
| 302 | + | var err error | |||
| 303 | + | if m, err = srv.ReadMessage(); err != nil { | |||
| 304 | + | return | |||
| 305 | + | } | |||
| 306 | + | if m.Type == zerobus.TypeMethodCall { | |||
| 307 | + | break | |||
| 308 | + | } | |||
| 309 | + | } | |||
| 310 | + | e := srv.NewReply(m, "u"+layoutSig) | |||
| 311 | + | e.Uint32(1) | |||
| 312 | + | root.encode(e) | |||
| 313 | + | _, _ = srv.Send() | |||
| 314 | + | }() | |||
| 315 | + | cli.NewCall(srv.UniqueName(), "/MenuBar", menuIface, "GetLayout", "") | |||
| 316 | + | m, err := cli.Call() | |||
| 317 | + | if err != nil { | |||
| 318 | + | t.Fatal(err) | |||
| 319 | + | } | |||
| 320 | + | items, ids, err := decodeLayout(m.Body()) | |||
| 321 | + | if err != nil { | |||
| 322 | + | t.Fatal(err) | |||
| 323 | + | } | |||
| 324 | + | _ = srv.Close() | |||
| 325 | + | <-done | |||
| 326 | + | return items, ids | |||
| 327 | + | } | |||
M internal/tray/tray_test.go +17 -1
| @@ -71,13 +71,17 @@ type fakeItem struct { | |||
| 71 | 71 | slowFor time.Duration // how long slow takes | |
| 72 | 72 | tip *[2]string // its ToolTip title and description, set through run | |
| 73 | 73 | title string // its Title property, set through run | |
| 74 | + | ||
| 75 | + | // A dbusmenu at /MenuBar, set through run. | |
| 76 | + | menu *tnode | |
| 77 | + | mcalls chan string // the dbusmenu calls it received | |
| 74 | 78 | } | |
| 75 | 79 | ||
| 76 | 80 | func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem { | |
| 77 | 81 | c := dial(t, addr) | |
| 78 | 82 | f := &fakeItem{c: c, name: c.UniqueName(), driver: dial(t, addr), cmds: make(chan func(), 1), | |
| 79 | 83 | cur: [3]string{id, iconName, "Active"}, pixel: pixel, closed: make(chan struct{}), | |
| 80 | - | calls: make(chan string, 32)} | |
| 84 | + | calls: make(chan string, 32), mcalls: make(chan string, 32)} | |
| 81 | 85 | go f.serve() | |
| 82 | 86 | return f | |
| 83 | 87 | } | |
| @@ -108,6 +112,12 @@ func (f *fakeItem) serve() { | |||
| 108 | 112 | (<-f.cmds)() | |
| 109 | 113 | continue | |
| 110 | 114 | } | |
| 115 | + | if m.Type == zerobus.TypeMethodCall && m.Interface == menuIface { | |
| 116 | + | if !f.menuCall(m) { | |
| 117 | + | return | |
| 118 | + | } | |
| 119 | + | continue | |
| 120 | + | } | |
| 111 | 121 | if m.Type == zerobus.TypeMethodCall && m.Interface == itemIface { | |
| 112 | 122 | if !f.action(m) { | |
| 113 | 123 | return | |
| @@ -136,6 +146,12 @@ func (f *fakeItem) serve() { | |||
| 136 | 146 | e.Str(f.tip[0]) | |
| 137 | 147 | e.Str(f.tip[1]) | |
| 138 | 148 | } | |
| 149 | + | if f.menu != nil { | |
| 150 | + | e.Struct() | |
| 151 | + | e.Str("Menu") | |
| 152 | + | e.Variant("o") | |
| 153 | + | e.ObjectPath("/MenuBar") | |
| 154 | + | } | |
| 139 | 155 | if f.title != "" { | |
| 140 | 156 | e.Struct() | |
| 141 | 157 | e.Str("Title") | |
| @@ -71,13 +71,17 @@ type fakeItem struct { | |||||
| 71 | slowFor time.Duration // how long slow takes | 71 | slowFor time.Duration // how long slow takes | ||
| 72 | tip *[2]string // its ToolTip title and description, set through run | 72 | tip *[2]string // its ToolTip title and description, set through run | ||
| 73 | title string // its Title property, set through run | 73 | title string // its Title property, set through run | ||
| 74 | + | ||||
| 75 | + | // A dbusmenu at /MenuBar, set through run. | |||
| 76 | + | menu *tnode | |||
| 77 | + | mcalls chan string // the dbusmenu calls it received | |||
| 74 | } | 78 | } | ||
| 75 | 79 | ||||
| 76 | func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem { | 80 | func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem { | ||
| 77 | c := dial(t, addr) | 81 | c := dial(t, addr) | ||
| 78 | f := &fakeItem{c: c, name: c.UniqueName(), driver: dial(t, addr), cmds: make(chan func(), 1), | 82 | f := &fakeItem{c: c, name: c.UniqueName(), driver: dial(t, addr), cmds: make(chan func(), 1), | ||
| 79 | cur: [3]string{id, iconName, "Active"}, pixel: pixel, closed: make(chan struct{}), | 83 | cur: [3]string{id, iconName, "Active"}, pixel: pixel, closed: make(chan struct{}), | ||
| 80 | - | calls: make(chan string, 32)} | 84 | + | calls: make(chan string, 32), mcalls: make(chan string, 32)} |
| 81 | go f.serve() | 85 | go f.serve() | ||
| 82 | return f | 86 | return f | ||
| 83 | } | 87 | } | ||
| @@ -108,6 +112,12 @@ func (f *fakeItem) serve() { | |||||
| 108 | (<-f.cmds)() | 112 | (<-f.cmds)() | ||
| 109 | continue | 113 | continue | ||
| 110 | } | 114 | } | ||
| 115 | + | if m.Type == zerobus.TypeMethodCall && m.Interface == menuIface { | |||
| 116 | + | if !f.menuCall(m) { | |||
| 117 | + | return | |||
| 118 | + | } | |||
| 119 | + | continue | |||
| 120 | + | } | |||
| 111 | if m.Type == zerobus.TypeMethodCall && m.Interface == itemIface { | 121 | if m.Type == zerobus.TypeMethodCall && m.Interface == itemIface { | ||
| 112 | if !f.action(m) { | 122 | if !f.action(m) { | ||
| 113 | return | 123 | return | ||
| @@ -136,6 +146,12 @@ func (f *fakeItem) serve() { | |||||
| 136 | e.Str(f.tip[0]) | 146 | e.Str(f.tip[0]) | ||
| 137 | e.Str(f.tip[1]) | 147 | e.Str(f.tip[1]) | ||
| 138 | } | 148 | } | ||
| 149 | + | if f.menu != nil { | |||
| 150 | + | e.Struct() | |||
| 151 | + | e.Str("Menu") | |||
| 152 | + | e.Variant("o") | |||
| 153 | + | e.ObjectPath("/MenuBar") | |||
| 154 | + | } | |||
| 139 | if f.title != "" { | 155 | if f.title != "" { | ||
| 140 | e.Struct() | 156 | e.Struct() | ||
| 141 | e.Str("Title") | 157 | e.Str("Title") | ||