263 lines
7.5 KiB
Go
263 lines
7.5 KiB
Go
// 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]
|
|
}
|