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,276 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/notification"
|
||||
"git.stormux.org/storm/cthulhu/internal/notificationui"
|
||||
"git.stormux.org/storm/cthulhu/internal/platform/session"
|
||||
)
|
||||
|
||||
const messageClipboardCopiedFull = "Copied selection to clipboard."
|
||||
|
||||
type notificationListWorker struct {
|
||||
logger *slog.Logger
|
||||
presenter *notification.Presenter
|
||||
speech speechEnqueuer
|
||||
open chan struct{}
|
||||
done chan struct{}
|
||||
cancel context.CancelFunc
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newNotificationListWorker(logger *slog.Logger, presenter *notification.Presenter, speech speechEnqueuer) *notificationListWorker {
|
||||
return ¬ificationListWorker{
|
||||
logger: logger, presenter: presenter, speech: speech,
|
||||
open: make(chan struct{}, 1), done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (worker *notificationListWorker) Start(ctx context.Context) error {
|
||||
ctx, worker.cancel = context.WithCancel(ctx)
|
||||
go func() {
|
||||
defer close(worker.done)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-worker.open:
|
||||
if err := worker.window(ctx); err != nil && ctx.Err() == nil {
|
||||
worker.logger.Error("notification list window", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (worker *notificationListWorker) Open() {
|
||||
worker.presenter.RefreshLive()
|
||||
if len(worker.presenter.Entries()) == 0 {
|
||||
presentNotifications(worker.speech, []string{notification.MessageNoMessages})
|
||||
return
|
||||
}
|
||||
select {
|
||||
case worker.open <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (worker *notificationListWorker) Stop(ctx context.Context) error {
|
||||
worker.once.Do(func() {
|
||||
if worker.cancel != nil {
|
||||
worker.cancel()
|
||||
}
|
||||
})
|
||||
select {
|
||||
case <-worker.done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (worker *notificationListWorker) window(ctx context.Context) error {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
childCtx, stop := context.WithCancel(ctx)
|
||||
defer stop()
|
||||
command := exec.CommandContext(childCtx, executable, "--notification-list-ui")
|
||||
command.Env = session.GTKEnvironment()
|
||||
command.WaitDelay = time.Second
|
||||
in, err := command.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := command.StdoutPipe()
|
||||
if err != nil {
|
||||
in.Close()
|
||||
return err
|
||||
}
|
||||
command.Stderr = os.Stderr
|
||||
if err := command.Start(); err != nil {
|
||||
in.Close()
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
defer func() { stop(); in.Close(); out.Close(); _ = command.Wait() }()
|
||||
|
||||
encoder := json.NewEncoder(in)
|
||||
if err := encoder.Encode(worker.snapshot("")); err != nil {
|
||||
return err
|
||||
}
|
||||
requests := make(chan notificationui.Request, 1)
|
||||
readDone := make(chan error, 1)
|
||||
readerStopped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(readerStopped)
|
||||
defer stop()
|
||||
scanner := bufio.NewScanner(out)
|
||||
scanner.Buffer(make([]byte, 4096), 16*1024)
|
||||
for scanner.Scan() {
|
||||
var request notificationui.Request
|
||||
if err := json.Unmarshal(scanner.Bytes(), &request); err != nil {
|
||||
readDone <- err
|
||||
return
|
||||
}
|
||||
select {
|
||||
case requests <- request:
|
||||
case <-childCtx.Done():
|
||||
readDone <- childCtx.Err()
|
||||
return
|
||||
}
|
||||
}
|
||||
readDone <- scanner.Err()
|
||||
}()
|
||||
defer func() {
|
||||
stop()
|
||||
out.Close()
|
||||
<-readerStopped
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-worker.open:
|
||||
if err := encoder.Encode(notificationui.Response{Present: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
case err := <-readDone:
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
case request := <-requests:
|
||||
response, close := worker.handle(request)
|
||||
if close {
|
||||
response.Close = true
|
||||
}
|
||||
if err := encoder.Encode(response); err != nil {
|
||||
return err
|
||||
}
|
||||
if close {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (worker *notificationListWorker) handle(request notificationui.Request) (notificationui.Response, bool) {
|
||||
worker.presenter.RefreshLive()
|
||||
entries := worker.listEntries()
|
||||
selected := notification.Entry{}
|
||||
for _, entry := range entries {
|
||||
if entry.ID == request.Selection {
|
||||
selected = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case request.Copy:
|
||||
presentNotifications(worker.speech, []string{messageClipboardCopiedFull})
|
||||
case request.Dismiss:
|
||||
if selected.ID == "" {
|
||||
presentNotifications(worker.speech, []string{notification.MessageUnavailable})
|
||||
} else {
|
||||
messages, _ := worker.presenter.DismissEntry(selected)
|
||||
presentNotifications(worker.speech, messages)
|
||||
}
|
||||
case request.Clear:
|
||||
worker.presenter.Clear()
|
||||
presentNotifications(worker.speech, []string{notification.MessageNoMessages})
|
||||
return notificationui.Response{}, true
|
||||
case request.Action != "":
|
||||
if selected.ID == "" {
|
||||
presentNotifications(worker.speech, []string{notification.MessageUnavailable})
|
||||
} else {
|
||||
messages, _ := worker.presenter.InvokeActionEntry(selected, request.Action)
|
||||
presentNotifications(worker.speech, messages)
|
||||
}
|
||||
}
|
||||
if request.Dismiss {
|
||||
return worker.snapshot(worker.selectionAfterRemoval(request)), false
|
||||
}
|
||||
return worker.snapshot(request.Selection), false
|
||||
}
|
||||
|
||||
func (worker *notificationListWorker) snapshot(selected string) notificationui.Response {
|
||||
entries := worker.listEntries()
|
||||
response := notificationui.Response{
|
||||
Title: worker.presenter.ListTitle(), Entries: make([]notificationui.Entry, len(entries)),
|
||||
}
|
||||
for index, entry := range entries {
|
||||
response.Entries[index] = notificationui.Entry{
|
||||
ID: entry.ID, Message: entry.Message, Received: worker.presenter.ReceivedTime(entry.Timestamp),
|
||||
CanControl: worker.presenter.CanControlEntry(entry), Actions: worker.actions(entry),
|
||||
}
|
||||
}
|
||||
if len(response.Entries) != 0 {
|
||||
response.Selected = response.Entries[0].ID
|
||||
for _, entry := range response.Entries {
|
||||
if entry.ID == selected {
|
||||
response.Selected = selected
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func (worker *notificationListWorker) listEntries() []notification.Entry {
|
||||
history := worker.presenter.Entries()
|
||||
entries := make([]notification.Entry, 0, len(history))
|
||||
for index := len(history) - 1; index >= 0; index-- {
|
||||
entry := history[index]
|
||||
entry.ID = strconv.Itoa(index)
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func (worker *notificationListWorker) selectionAfterRemoval(request notificationui.Request) string {
|
||||
row := 0
|
||||
if request.Row != nil {
|
||||
row = *request.Row
|
||||
}
|
||||
entries := worker.listEntries()
|
||||
if len(entries) == 0 || row < 0 {
|
||||
return ""
|
||||
}
|
||||
if row >= len(entries) {
|
||||
row = len(entries) - 1
|
||||
}
|
||||
return entries[row].ID
|
||||
}
|
||||
|
||||
func (worker *notificationListWorker) actions(entry notification.Entry) []notificationui.Action {
|
||||
actions := worker.presenter.ActionsForEntry(entry)
|
||||
keys := make([]string, 0, len(actions))
|
||||
for key := range actions {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
result := make([]notificationui.Action, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
result = append(result, notificationui.Action{Key: key, Label: actions[key]})
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in new issue
Block a user