More LibreOffice work.

This commit is contained in:
Storm Dragon committed 2026-10-15 01:10:43 -04:00
1 parent 4ac59d4ced
commit b5a5b86dda
15 files changed
+768 -28

No files matched your search

@@ -0,0 +1,102 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package presentation
import (
"fmt"
"slices"
"strings"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func calcColumnLabel(column int) string {
label := ""
for column++; column > 0; column = (column - 1) / 26 {
label = string(rune('A'+(column-1)%26)) + label
}
return label
}
func calcCellLabel(cell atspi.LibreOfficeCell) string {
return fmt.Sprintf("%s%d", calcColumnLabel(cell.Column), cell.Row+1)
}
// LibreOfficeSelectionText formats copied metadata only. A large selection's
// endpoints never imply a filled rectangle. Small disjoint selections retain
// their gaps; at most eight rectangles are spoken before using a count.
func LibreOfficeSelectionText(selection atspi.LibreOfficeSelection, action string) string {
if selection.Kind == "all" {
return "All cells " + action + "."
}
if selection.Count == 0 {
return ""
}
if !selection.Complete || len(selection.Cells) > 64 {
return libreOfficeSelectionCount(selection, action)
}
cells := slices.Clone(selection.Cells)
slices.SortFunc(cells, func(a, b atspi.LibreOfficeCell) int {
if a.Row != b.Row {
return a.Row - b.Row
}
return a.Column - b.Column
})
cells = slices.Compact(cells)
type rectangle struct{ first, last atspi.LibreOfficeCell }
var ranges []rectangle
for index := 0; index < len(cells); {
first, last := cells[index], cells[index]
index++
for index < len(cells) && (selection.Kind == "rows" && cells[index].Row == last.Row+1 ||
selection.Kind != "rows" && cells[index].Row == last.Row && cells[index].Column == last.Column+1) {
last = cells[index]
index++
}
merged := false
if selection.Kind == "cells" {
for i := range ranges {
previous := &ranges[i]
if previous.first.Column == first.Column && previous.last.Column == last.Column && previous.last.Row+1 == first.Row {
previous.last.Row = last.Row
merged = true
break
}
}
}
if !merged {
ranges = append(ranges, rectangle{first, last})
}
}
if len(ranges) > 8 {
return libreOfficeSelectionCount(selection, action)
}
var messages []string
for _, span := range ranges {
first, last := calcCellLabel(span.first), calcCellLabel(span.last)
singular, plural := "Cell", "Cells"
if selection.Kind == "rows" {
first, last = fmt.Sprint(span.first.Row+1), fmt.Sprint(span.last.Row+1)
singular, plural = "Row", "Rows"
} else if selection.Kind == "columns" {
first, last = calcColumnLabel(span.first.Column), calcColumnLabel(span.last.Column)
singular, plural = "Column", "Columns"
}
if first == last {
messages = append(messages, fmt.Sprintf("%s %s %s.", singular, first, action))
} else {
messages = append(messages, fmt.Sprintf("%s %s to %s %s.", plural, first, last, action))
}
}
return strings.Join(messages, " ")
}
func libreOfficeSelectionCount(selection atspi.LibreOfficeSelection, action string) string {
prefix := ""
if selection.CountLimited {
prefix = "At least "
}
return fmt.Sprintf("%s%d %s %s.", prefix, selection.Count, selection.Kind, action)
}
@@ -0,0 +1,45 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package presentation
import (
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func TestLibreOfficeSelectionPreservesGapsAndBoundsOutput(t *testing.T) {
for _, test := range []struct {
name string
selection atspi.LibreOfficeSelection
want string
}{
{"rectangle", atspi.LibreOfficeSelection{Kind: "cells", Count: 4, Complete: true,
Cells: []atspi.LibreOfficeCell{{Row: 0, Column: 0}, {Row: 0, Column: 1}, {Row: 1, Column: 0}, {Row: 1, Column: 1}}}, "Cells A1 to B2 selected."},
{"disjoint", atspi.LibreOfficeSelection{Kind: "cells", Count: 4, Complete: true,
Cells: []atspi.LibreOfficeCell{{Row: 0, Column: 0}, {Row: 0, Column: 1}, {Row: 0, Column: 3}, {Row: 1, Column: 3}}}, "Cells A1 to B1 selected. Cells D1 to D2 selected."},
{"row gaps", atspi.LibreOfficeSelection{Kind: "rows", Count: 3, Complete: true,
Cells: []atspi.LibreOfficeCell{{Row: 0, Column: 0}, {Row: 1, Column: 0}, {Row: 3, Column: 0}}}, "Rows 1 to 2 selected. Row 4 selected."},
{"column labels", atspi.LibreOfficeSelection{Kind: "columns", Count: 2, Complete: true,
Cells: []atspi.LibreOfficeCell{{Row: 0, Column: 25}, {Row: 0, Column: 26}}}, "Columns Z to AA selected."},
{"large", atspi.LibreOfficeSelection{Kind: "cells", Count: 1000000000,
First: atspi.LibreOfficeCell{Row: 0, Column: 0}, Last: atspi.LibreOfficeCell{Row: 999999, Column: 999}}, "1000000000 cells selected."},
{"capped", atspi.LibreOfficeSelection{Kind: "cells", Count: 2147483647, CountLimited: true}, "At least 2147483647 cells selected."},
{"all", atspi.LibreOfficeSelection{Kind: "all", Complete: true}, "All cells selected."},
} {
t.Run(test.name, func(t *testing.T) {
if got := LibreOfficeSelectionText(test.selection, "selected"); got != test.want {
t.Fatalf("got %q, want %q", got, test.want)
}
})
}
selection := atspi.LibreOfficeSelection{Kind: "cells", Count: 9, Complete: true}
for index := 0; index < 9; index++ {
selection.Cells = append(selection.Cells, atspi.LibreOfficeCell{Row: index, Column: index})
}
if got := LibreOfficeSelectionText(selection, "unselected"); got != "9 cells unselected." {
t.Fatalf("fragmented selection = %q", got)
}
}