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,262 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
)
|
||||
|
||||
type characterAttributeSource interface {
|
||||
ReadCharacterAttributes(context.Context, atspi.ObjectID) (atspi.CharacterAttributes, error)
|
||||
}
|
||||
|
||||
type characterAttributeResult struct {
|
||||
object atspi.ObjectID
|
||||
sequence uint64
|
||||
values map[string]string
|
||||
err error
|
||||
}
|
||||
|
||||
type characterAttributeRequest struct {
|
||||
object atspi.ObjectID
|
||||
sequence uint64
|
||||
}
|
||||
|
||||
type characterAttributeReader struct {
|
||||
requests chan characterAttributeRequest
|
||||
results chan characterAttributeResult
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// This reader owns one native query at a time and one waiting request. Native
|
||||
// work stays on AT-SPI's GLib owner; the event loop remains responsive.
|
||||
func startCharacterAttributeReader(parent context.Context, adapter Adapter) *characterAttributeReader {
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
reader := &characterAttributeReader{
|
||||
requests: make(chan characterAttributeRequest, 1),
|
||||
results: make(chan characterAttributeResult, 1),
|
||||
cancel: cancel,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
source, supported := adapter.(characterAttributeSource)
|
||||
go func() {
|
||||
defer close(reader.done)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case request := <-reader.requests:
|
||||
result := characterAttributeResult{object: request.object, sequence: request.sequence}
|
||||
if supported && request.object.Valid() {
|
||||
deadline, cancel := context.WithTimeout(ctx, 1200*time.Millisecond)
|
||||
attributes, err := source.ReadCharacterAttributes(deadline, request.object)
|
||||
cancel()
|
||||
result.values, result.err = attributes.Values, err
|
||||
} else {
|
||||
result.err = atspi.ErrStaleObject
|
||||
}
|
||||
select {
|
||||
case reader.results <- result:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return reader
|
||||
}
|
||||
|
||||
func (reader *characterAttributeReader) request(object atspi.ObjectID, sequence uint64) {
|
||||
if !object.Valid() {
|
||||
return
|
||||
}
|
||||
request := characterAttributeRequest{object: object, sequence: sequence}
|
||||
select {
|
||||
case reader.requests <- request:
|
||||
default:
|
||||
select {
|
||||
case <-reader.requests:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case reader.requests <- request:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (reader *characterAttributeReader) stop() {
|
||||
reader.cancel()
|
||||
<-reader.done
|
||||
}
|
||||
|
||||
func presentCharacterAttributes(speech speechEnqueuer, result characterAttributeResult, useColorNames bool, configured ...config.TextAttributeSettings) {
|
||||
if errors.Is(result.err, atspi.ErrStaleObject) || errors.Is(result.err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
message := "Text attributes are unavailable."
|
||||
if result.err == nil {
|
||||
if parts := spokenCharacterAttributes(result.values, useColorNames, configured...); len(parts) != 0 {
|
||||
message = strings.Join(parts, ", ")
|
||||
} else {
|
||||
message = "No text attributes to speak."
|
||||
}
|
||||
}
|
||||
speech.Interrupt()
|
||||
speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "character-attributes"})
|
||||
}
|
||||
|
||||
var characterAttributeOrder = [...]struct {
|
||||
key, label, ignore string
|
||||
}{
|
||||
{"size", "font size", ""},
|
||||
{"family-name", "font family", ""},
|
||||
{"weight", "weight", "400"},
|
||||
{"indent", "indent", "0"},
|
||||
{"underline", "underline", "none"},
|
||||
{"strikethrough", "strikethrough", "false"},
|
||||
{"justification", "justification", "left"},
|
||||
{"style", "style", "normal"},
|
||||
{"paragraph-style", "paragraph style", ""},
|
||||
{"text-spelling", "spelling", "none"},
|
||||
{"fg-color", "foreground color", ""},
|
||||
{"bg-color", "background color", ""},
|
||||
}
|
||||
|
||||
func spokenCharacterAttributes(values map[string]string, useColorNames bool, configured ...config.TextAttributeSettings) []string {
|
||||
parts := make([]string, 0, len(characterAttributeOrder))
|
||||
order := make([]struct{ key, label, ignore string }, 0, len(characterAttributeOrder))
|
||||
if len(configured) == 0 {
|
||||
order = append(order, characterAttributeOrder[:]...)
|
||||
} else {
|
||||
for _, choice := range configured[0].Choices {
|
||||
if !choice.Speak {
|
||||
continue
|
||||
}
|
||||
for _, known := range characterAttributeOrder {
|
||||
if choice.Key == known.key {
|
||||
order = append(order, known)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, attribute := range order {
|
||||
if attribute.key == "text-spelling" {
|
||||
if atspi.TextAttributesMisspelled(values) {
|
||||
parts = append(parts, "misspelled")
|
||||
}
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(values[attribute.key])
|
||||
if value == "" || value == attribute.ignore || value == "0" && attribute.ignore == "0" {
|
||||
continue
|
||||
}
|
||||
switch attribute.key {
|
||||
case "weight":
|
||||
if number, ok := parseAttributeInteger(value); value == "bold" || ok && number > 400 {
|
||||
parts = append(parts, "bold")
|
||||
continue
|
||||
}
|
||||
case "family-name":
|
||||
value = strings.Trim(strings.TrimSpace(strings.Split(value, ",")[0]), "\"")
|
||||
case "fg-color", "bg-color":
|
||||
if color, ok := describeAttributeColor(value, useColorNames); ok {
|
||||
value = color
|
||||
}
|
||||
}
|
||||
parts = append(parts, attribute.label+": "+value)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func parseAttributeInteger(value string) (int, bool) {
|
||||
number, err := strconv.Atoi(value)
|
||||
return number, err == nil
|
||||
}
|
||||
|
||||
func describeAttributeColor(value string, useColorNames bool) (string, bool) {
|
||||
red, green, blue, ok := parseAttributeColor(value)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if useColorNames {
|
||||
return nearestCSSColor(red, green, blue), true
|
||||
}
|
||||
return fmt.Sprintf("%d %d %d", red, green, blue), true
|
||||
}
|
||||
|
||||
func parseAttributeColor(value string) (int, int, int, bool) {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if strings.HasPrefix(value, "#") {
|
||||
if len(value) == 4 {
|
||||
value = "#" + strings.Repeat(value[1:2], 2) + strings.Repeat(value[2:3], 2) + strings.Repeat(value[3:4], 2)
|
||||
}
|
||||
if len(value) != 7 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
var channels [3]int
|
||||
for index := range channels {
|
||||
number, err := strconv.ParseUint(value[1+index*2:3+index*2], 16, 8)
|
||||
if err != nil {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
channels[index] = int(number)
|
||||
}
|
||||
return channels[0], channels[1], channels[2], true
|
||||
}
|
||||
value = strings.TrimPrefix(value, "rgb(")
|
||||
value = strings.TrimSuffix(value, ")")
|
||||
fields := strings.Split(value, ",")
|
||||
if len(fields) != 3 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
var channels [3]int
|
||||
for index, field := range fields {
|
||||
number, err := strconv.Atoi(strings.TrimSpace(field))
|
||||
if err != nil || number < 0 || number > 65535 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
channels[index] = number
|
||||
}
|
||||
if channels[0] > 255 || channels[1] > 255 || channels[2] > 255 {
|
||||
for index, number := range channels {
|
||||
channels[index] = (number*255 + 32767) / 65535
|
||||
}
|
||||
}
|
||||
return channels[0], channels[1], channels[2], true
|
||||
}
|
||||
|
||||
func nearestCSSColor(red, green, blue int) string {
|
||||
hex := fmt.Sprintf("#%02x%02x%02x", red, green, blue)
|
||||
if name := cssColorNames[hex]; name != "" {
|
||||
return name
|
||||
}
|
||||
bestDistance, secondDistance := int(^uint(0)>>1), int(^uint(0)>>1)
|
||||
bestHex, secondHex := "", ""
|
||||
for candidate := range cssColorNames {
|
||||
r, g, b, _ := parseAttributeColor(candidate)
|
||||
distance := (r-red)*(r-red) + (g-green)*(g-green) + (b-blue)*(b-blue)
|
||||
if distance < bestDistance || distance == bestDistance && candidate < bestHex {
|
||||
secondDistance, secondHex = bestDistance, bestHex
|
||||
bestDistance, bestHex = distance, candidate
|
||||
} else if distance < secondDistance || distance == secondDistance && candidate < secondHex {
|
||||
secondDistance, secondHex = distance, candidate
|
||||
}
|
||||
}
|
||||
if (bestHex == "#000000" || bestHex == "#ffffff") && secondHex != "" && secondDistance-bestDistance < bestDistance {
|
||||
bestHex = secondHex
|
||||
}
|
||||
return cssColorNames[bestHex]
|
||||
}
|
||||
Reference in new issue
Block a user