Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,442 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var noMessages []string
|
||||
|
||||
const historySize = 55
|
||||
|
||||
const (
|
||||
MessageNotification = "Notification"
|
||||
MessageListBottom = "Bottom"
|
||||
MessageListTop = "Top"
|
||||
MessageNoMessages = "No notification messages"
|
||||
MessageUnavailable = "Notification is no longer available"
|
||||
MessageNoActions = "No notification actions available"
|
||||
MessageDismissed = "Notification dismissed"
|
||||
MessageActionInvoked = "Notification action invoked"
|
||||
SourceGeneric = "generic"
|
||||
ListTitle = "Notifications"
|
||||
ListColumnMessages = "Notifications"
|
||||
ListColumnReceived = "Received"
|
||||
ListDismissButton = "Dismiss Notification"
|
||||
ListActionsButton = "Notification Actions"
|
||||
ListActionsTitle = "Notification Actions"
|
||||
ListInvokeActionButton = "Invoke Action"
|
||||
ListClearButton = "Clear"
|
||||
ListCopyButton = "Copy"
|
||||
ListCloseButton = "Close"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
ID string
|
||||
Message string
|
||||
Timestamp time.Time
|
||||
Source string
|
||||
SourceGeneration int
|
||||
NotificationID *int
|
||||
Live bool
|
||||
Actions map[string]string
|
||||
AppName string
|
||||
Summary string
|
||||
Body string
|
||||
Urgency int
|
||||
DesktopEntry string
|
||||
}
|
||||
|
||||
type SaveOptions struct {
|
||||
Source string
|
||||
SourceGeneration int
|
||||
NotificationID *int
|
||||
Live bool
|
||||
Actions map[string]string
|
||||
AppName string
|
||||
Summary string
|
||||
Body string
|
||||
Urgency int
|
||||
DesktopEntry string
|
||||
}
|
||||
|
||||
type LiveMetadata struct {
|
||||
Message string
|
||||
Actions map[string]string
|
||||
AppName string
|
||||
Summary string
|
||||
Body string
|
||||
Urgency int
|
||||
DesktopEntry string
|
||||
}
|
||||
|
||||
type Presenter struct {
|
||||
mutex sync.Mutex
|
||||
now func() time.Time
|
||||
mako MakoController
|
||||
|
||||
entries []Entry
|
||||
currentIndex int
|
||||
}
|
||||
|
||||
func NewPresenter() *Presenter {
|
||||
return &Presenter{now: time.Now, currentIndex: -1}
|
||||
}
|
||||
|
||||
func NewPresenterAt(now func() time.Time) *Presenter {
|
||||
return &Presenter{now: now, currentIndex: -1}
|
||||
}
|
||||
|
||||
func (presenter *Presenter) SetMakoController(controller MakoController) {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
presenter.mako = controller
|
||||
}
|
||||
|
||||
func (presenter *Presenter) Save(message string, options ...SaveOptions) Entry {
|
||||
option := SaveOptions{}
|
||||
if len(options) != 0 {
|
||||
option = options[0]
|
||||
}
|
||||
if option.Source == "" {
|
||||
option.Source = SourceGeneric
|
||||
}
|
||||
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
if excess := len(presenter.entries) - historySize + 1; excess > 0 {
|
||||
presenter.entries = presenter.entries[excess:]
|
||||
}
|
||||
entry := Entry{
|
||||
Message: message,
|
||||
Timestamp: presenter.now(),
|
||||
Source: option.Source,
|
||||
SourceGeneration: option.SourceGeneration,
|
||||
NotificationID: option.NotificationID,
|
||||
Live: option.Live,
|
||||
Actions: cloneActions(option.Actions),
|
||||
AppName: option.AppName,
|
||||
Summary: option.Summary,
|
||||
Body: option.Body,
|
||||
Urgency: option.Urgency,
|
||||
DesktopEntry: option.DesktopEntry,
|
||||
}
|
||||
presenter.entries = append(presenter.entries, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
func (presenter *Presenter) Clear() {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
presenter.entries = nil
|
||||
presenter.currentIndex = -1
|
||||
}
|
||||
|
||||
func (presenter *Presenter) Entries() []Entry {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
return append([]Entry(nil), presenter.entries...)
|
||||
}
|
||||
|
||||
func (presenter *Presenter) EntriesForList() []Entry {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
return presenter.entriesForList()
|
||||
}
|
||||
|
||||
func (presenter *Presenter) ListTitle() string {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
return presenter.listTitle()
|
||||
}
|
||||
|
||||
func (presenter *Presenter) EntryText(entry Entry) string {
|
||||
presenter.mutex.Lock()
|
||||
now := presenter.now()
|
||||
presenter.mutex.Unlock()
|
||||
return entryText(entry, now)
|
||||
}
|
||||
|
||||
func (presenter *Presenter) ReceivedTime(timestamp time.Time) string {
|
||||
presenter.mutex.Lock()
|
||||
now := presenter.now()
|
||||
presenter.mutex.Unlock()
|
||||
return relativeTime(timestamp, now)
|
||||
}
|
||||
|
||||
func (presenter *Presenter) RefreshLive() bool {
|
||||
presenter.mutex.Lock()
|
||||
controller := presenter.mako
|
||||
presenter.mutex.Unlock()
|
||||
refresher, ok := controller.(interface{ Refresh(bool) bool })
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return refresher.Refresh(false)
|
||||
}
|
||||
|
||||
func (presenter *Presenter) Remove(entry Entry) bool {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
for index, candidate := range presenter.entries {
|
||||
if entriesEqual(candidate, entry) {
|
||||
presenter.entries = append(presenter.entries[:index], presenter.entries[index+1:]...)
|
||||
presenter.removeIndex(index)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (presenter *Presenter) SyncLive(source string, generation int, live map[int]LiveMetadata) {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
for index := range presenter.entries {
|
||||
entry := &presenter.entries[index]
|
||||
if entry.Source != source || entry.SourceGeneration != generation {
|
||||
continue
|
||||
}
|
||||
if entry.NotificationID == nil {
|
||||
continue
|
||||
}
|
||||
if metadata, exists := live[*entry.NotificationID]; exists {
|
||||
entry.Live = true
|
||||
if metadata.Message != "" {
|
||||
entry.Message = metadata.Message
|
||||
}
|
||||
entry.Actions = cloneActions(metadata.Actions)
|
||||
entry.AppName = metadata.AppName
|
||||
entry.Summary = metadata.Summary
|
||||
entry.Body = metadata.Body
|
||||
entry.Urgency = metadata.Urgency
|
||||
entry.DesktopEntry = metadata.DesktopEntry
|
||||
} else if entry.Live {
|
||||
entry.Live = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (presenter *Presenter) MarkSourceUnavailable(source string) {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
for index := range presenter.entries {
|
||||
if presenter.entries[index].Source == source && presenter.entries[index].Live {
|
||||
presenter.entries[index].Live = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (presenter *Presenter) CanControlEntry(entry Entry) bool {
|
||||
presenter.mutex.Lock()
|
||||
controller := presenter.mako
|
||||
presenter.mutex.Unlock()
|
||||
return entry.Live && entry.NotificationID != nil && controller != nil && controller.IsCurrent(entry)
|
||||
}
|
||||
|
||||
func (presenter *Presenter) ActionsForEntry(entry Entry) map[string]string {
|
||||
if !presenter.CanControlEntry(entry) {
|
||||
return map[string]string{}
|
||||
}
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
return cloneActions(entry.Actions)
|
||||
}
|
||||
|
||||
func (presenter *Presenter) DismissEntry(entry Entry) ([]string, bool) {
|
||||
if !presenter.CanControlEntry(entry) {
|
||||
return []string{MessageUnavailable}, false
|
||||
}
|
||||
presenter.mutex.Lock()
|
||||
controller := presenter.mako
|
||||
presenter.mutex.Unlock()
|
||||
if controller == nil || !controller.Dismiss(*entry.NotificationID) {
|
||||
return []string{MessageUnavailable}, false
|
||||
}
|
||||
entry.Live = false
|
||||
presenter.Remove(entry)
|
||||
return []string{MessageDismissed}, true
|
||||
}
|
||||
|
||||
func (presenter *Presenter) InvokeActionEntry(entry Entry, actionKey string) ([]string, bool) {
|
||||
if !presenter.CanControlEntry(entry) {
|
||||
return []string{MessageUnavailable}, false
|
||||
}
|
||||
presenter.mutex.Lock()
|
||||
controller := presenter.mako
|
||||
presenter.mutex.Unlock()
|
||||
if controller == nil || !controller.InvokeAction(*entry.NotificationID, actionKey) {
|
||||
return []string{MessageUnavailable}, false
|
||||
}
|
||||
return []string{MessageActionInvoked}, true
|
||||
}
|
||||
|
||||
func (presenter *Presenter) PresentLast() []string {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
if len(presenter.entries) == 0 {
|
||||
return []string{MessageNoMessages}
|
||||
}
|
||||
presenter.currentIndex = -1
|
||||
return []string{entryText(presenter.entries[len(presenter.entries)-1], presenter.now())}
|
||||
}
|
||||
|
||||
func (presenter *Presenter) PresentPrevious() []string {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
if len(presenter.entries) == 0 {
|
||||
return []string{MessageNoMessages}
|
||||
}
|
||||
messages := noMessages
|
||||
next := presenter.currentIndex - 1
|
||||
if next < -len(presenter.entries) {
|
||||
messages = append(messages, MessageListTop)
|
||||
} else {
|
||||
presenter.currentIndex = next
|
||||
}
|
||||
current := len(presenter.entries) + presenter.currentIndex
|
||||
return append(messages, entryText(presenter.entries[current], presenter.now()))
|
||||
}
|
||||
|
||||
func (presenter *Presenter) PresentNext() []string {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
if len(presenter.entries) == 0 {
|
||||
return []string{MessageNoMessages}
|
||||
}
|
||||
messages := noMessages
|
||||
if presenter.currentIndex == -1 {
|
||||
messages = append(messages, MessageListBottom)
|
||||
} else {
|
||||
next := presenter.currentIndex + 1
|
||||
if next > -1 {
|
||||
messages = append(messages, MessageListBottom)
|
||||
} else {
|
||||
presenter.currentIndex = next
|
||||
}
|
||||
}
|
||||
current := len(presenter.entries) + presenter.currentIndex
|
||||
return append(messages, entryText(presenter.entries[current], presenter.now()))
|
||||
}
|
||||
|
||||
func (presenter *Presenter) removeIndex(index int) {
|
||||
if len(presenter.entries) == 0 {
|
||||
presenter.currentIndex = -1
|
||||
return
|
||||
}
|
||||
if presenter.currentIndex == -1 {
|
||||
return
|
||||
}
|
||||
// Navigation uses negative offsets from the newest entry; removal receives
|
||||
// a forward slice index. Preserve the selected entry when it survives, or
|
||||
// select its successor when removed, without retaining an out-of-range offset.
|
||||
current := len(presenter.entries) + 1 + presenter.currentIndex
|
||||
if index < current {
|
||||
current--
|
||||
}
|
||||
current = min(current, len(presenter.entries)-1)
|
||||
presenter.currentIndex = current - len(presenter.entries)
|
||||
}
|
||||
|
||||
func (presenter *Presenter) entriesForList() []Entry {
|
||||
entries := append([]Entry(nil), presenter.entries...)
|
||||
for left, right := 0, len(entries)-1; left < right; left, right = left+1, right-1 {
|
||||
entries[left], entries[right] = entries[right], entries[left]
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func (presenter *Presenter) listTitle() string {
|
||||
return plural(len(presenter.entries), "%d notification", "%d notifications")
|
||||
}
|
||||
|
||||
func entryText(entry Entry, now time.Time) string {
|
||||
return fmt.Sprintf("%s %s", entry.Message, relativeTime(entry.Timestamp, now))
|
||||
}
|
||||
|
||||
func relativeTime(timestamp, now time.Time) string {
|
||||
elapsed := now.Sub(timestamp)
|
||||
switch {
|
||||
case elapsed < time.Minute:
|
||||
count := int(elapsed.Seconds())
|
||||
return plural(count, "%d second ago", "%d seconds ago")
|
||||
case elapsed < time.Hour:
|
||||
count := roundLikePython(elapsed.Minutes())
|
||||
return plural(count, "%d minute ago", "%d minutes ago")
|
||||
case elapsed < 24*time.Hour:
|
||||
count := roundLikePython(elapsed.Hours())
|
||||
return plural(count, "%d hour ago", "%d hours ago")
|
||||
default:
|
||||
count := roundLikePython(elapsed.Hours() / 24)
|
||||
return plural(count, "%d day ago", "%d days ago")
|
||||
}
|
||||
}
|
||||
|
||||
func plural(count int, singular, pluralFormat string) string {
|
||||
if count == 1 {
|
||||
return fmt.Sprintf(singular, count)
|
||||
}
|
||||
return fmt.Sprintf(pluralFormat, count)
|
||||
}
|
||||
|
||||
func roundLikePython(value float64) int {
|
||||
lower := math.Floor(value)
|
||||
fraction := value - lower
|
||||
switch {
|
||||
case fraction > 0.5:
|
||||
return int(lower) + 1
|
||||
case fraction == 0.5 && int(lower)%2 != 0:
|
||||
return int(lower) + 1
|
||||
default:
|
||||
return int(lower)
|
||||
}
|
||||
}
|
||||
|
||||
func cloneActions(actions map[string]string) map[string]string {
|
||||
if len(actions) == 0 {
|
||||
return map[string]string{}
|
||||
}
|
||||
clone := make(map[string]string, len(actions))
|
||||
for key, value := range actions {
|
||||
clone[key] = value
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func entriesEqual(left, right Entry) bool {
|
||||
return left.Message == right.Message &&
|
||||
left.Timestamp.Equal(right.Timestamp) &&
|
||||
left.Source == right.Source &&
|
||||
left.SourceGeneration == right.SourceGeneration &&
|
||||
notificationIDsEqual(left.NotificationID, right.NotificationID) &&
|
||||
actionsEqual(left.Actions, right.Actions) &&
|
||||
left.AppName == right.AppName &&
|
||||
left.Summary == right.Summary &&
|
||||
left.Body == right.Body &&
|
||||
left.Urgency == right.Urgency &&
|
||||
left.DesktopEntry == right.DesktopEntry
|
||||
}
|
||||
|
||||
func notificationIDsEqual(left, right *int) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == right
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func actionsEqual(left, right map[string]string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for key, value := range left {
|
||||
if right[key] != value {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in new issue
Block a user