// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "strconv" "strings" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/notification" ) func presentNotifications(speech speechEnqueuer, messages []string) { speech.Interrupt() for _, message := range messages { // Notifications remain audible while XTerm owns keyboard handling. // The output gate still rejects these requests during screen lock. speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "notifications", BypassFocusGate: true}) } } type notificationAnnouncer struct { presenter *notification.Presenter speech speechEnqueuer braille interface { Flash(string, config.BrailleOptions) bool } live *liveSettings } func (announcer *notificationAnnouncer) Announce(entry notification.Entry) { if announcer.braille != nil && announcer.live != nil { options := announcer.live.Load().Braille message := entry.Message if !options.FlashDetailed && entry.Summary != "" { message = entry.Summary } announcer.braille.Flash(message, options) } announcer.presenter.Save(entry.Message, notification.SaveOptions{ Source: entry.Source, SourceGeneration: entry.SourceGeneration, NotificationID: entry.NotificationID, Live: entry.Live, Actions: entry.Actions, AppName: entry.AppName, Summary: entry.Summary, Body: entry.Body, Urgency: entry.Urgency, DesktopEntry: entry.DesktopEntry, }) announcer.speech.Interrupt() message := entry.Message if announcer.live != nil && !announcer.live.Load().Reading.MessagesAreDetailed && entry.Summary != "" { message = entry.Summary } announcer.speech.Enqueue(speechRequest{ Text: message, Role: "system", Cause: "mako-notification", }) } func notificationEvent(event atspi.Event) (string, []string, bool, bool) { applicationName := strings.ToLower(event.Source.ApplicationName) if event.Type == atspi.WindowCreateEvent { switch applicationName { case "notification-daemon", "mate-notification-daemon", "xfce4-notifyd": message := notification.MessageNotification if event.Source.Text != "" { message += " " + event.Source.Text } return message, []string{message}, true, true } } if applicationName == "notify-osd" { if event.Type == atspi.AccessibleNameEvent { message := event.Source.Name + " " + event.Source.Description if !event.Source.ValueKnown || event.Source.CurrentValue < 0 { return message, []string{notification.MessageNotification, message}, true, true } message = event.Source.Name + " " + strconv.Itoa(int(event.Source.CurrentValue)) return message, []string{message, message}, true, true } if event.Type == atspi.ValueChangedEvent || event.Type == atspi.AccessibleValueEvent { if !event.Source.ValueKnown || event.Source.CurrentValue < 0 { return "", nil, false, false } value := strconv.Itoa(int(event.Source.CurrentValue)) return value, []string{value}, true, false } } if event.Type == atspi.ShowingStateEvent && strings.EqualFold(event.Source.Role, "notification") && event.Detail1 != 0 { return notificationContent(event.Source), nil, false, true } return "", nil, false, false } func notificationContent(snapshot atspi.Snapshot) string { parts := []string{snapshot.Name} if snapshot.Text != "" && snapshot.Text != snapshot.Name { parts = append(parts, snapshot.Text) } if len(parts) == 1 && parts[0] == "" && snapshot.Description != "" { parts = []string{snapshot.Description} } var content []string for _, part := range parts { if part != "" && !containsString(content, part) { content = append(content, part) } } return strings.Join(content, " ") } func containsString(values []string, candidate string) bool { for _, value := range values { if value == candidate { return true } } return false }