// 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] }