Files
cthulhu/internal/app/character_attributes.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]
}