Skip to content

Commit 66de454

Browse files
authored
fix(linux): hint GTK 3 text fields (#1784)
1 parent bba935d commit 66de454

5 files changed

Lines changed: 352 additions & 23 deletions

File tree

‎docs/reference/configuration.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -587,9 +587,9 @@ clickable_roles = ["button", "link", "text_field"]
587587
| `radio` | `AXRadioButton` | `radio button`, `radio menu item` | `RadioButton` |
588588
| `switch` | `AXSwitch` † | `switch`, `toggle button` | — |
589589
| `disclosure` | `AXDisclosureTriangle` | — | — |
590-
| `text_field` | `AXTextField` | `entry`, `password text` | `Edit` |
591-
| `text_area` | `AXTextArea` | `entry` | `Edit` |
592-
| `search_field` | `AXSearchField` † | `entry` | `Edit` |
590+
| `text_field` | `AXTextField` | `entry` ‡, `password text` | `Edit` |
591+
| `text_area` | `AXTextArea` | `entry` ‡ | `Edit` |
592+
| `search_field` | `AXSearchField` † | `entry` ‡ | `Edit` |
593593
| `slider` | `AXSlider` | `slider` | `Slider` |
594594
| `stepper` | `AXIncrementor` | `spin button` | `Spinner` |
595595
| `tab` | `AXTabButton` † | `page tab` | `TabItem` |
@@ -600,7 +600,7 @@ clickable_roles = ["button", "link", "text_field"]
600600
| `row` | `AXRow` | `table row` | `TreeItem` |
601601
| `list_item` | `AXRow` | `list item` | `ListItem` |
602602
| `image` | `AXImage` | `image`, `icon` | `Image` |
603-
| `static_text` | `AXStaticText` | `static`, `label`, `text` | `Text` |
603+
| `static_text` | `AXStaticText` | `static`, `label`, `text` ‡ | `Text` |
604604
| `heading` | `AXHeading` | `heading` | — |
605605
| `color_well` | `AXColorWell` | `color chooser` | — |
606606
| `toolbar_button` | `AXToolbarButton` † | — | — |
@@ -614,6 +614,10 @@ differs from the shipped list, and always for `additional_clickable_roles`.
614614
generic, e.g. a search field is an `AXTextField` with subrole `AXSearchField`.
615615
Neru matches names against both role and subrole, so these work as written.
616616

617+
‡ GTK 3 reports its entries and text views as `text`, which other toolkits use
618+
for read-only text. Neru reads a `text` element as `entry` when AT-SPI marks
619+
it editable, and as `text` otherwise.
620+
617621
#### Native roles
618622

619623
Address any native role directly with its vocabulary prefix:

‎internal/adapter/accessibility/atspi/client.go‎

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ const (
4545

4646
// AT-SPI state bit indices (atspi-constants.h). All < 32, so they live in
4747
// the first uint32 of the state bitfield array.
48-
atspiStateActive = 1
49-
atspiStateShowing = 25
48+
atspiStateActive = 1
49+
atspiStateEditable = 7
50+
atspiStateShowing = 25
5051

5152
// Bit width of the int32 words AT-SPI packs its state and role sets into
5253
// (one bit per state/role index).
@@ -631,11 +632,24 @@ func (c *Client) materializeMatch(
631632
// Re-verify the role is in the requested set: a toolkit may return a role we
632633
// did not ask for, and this keeps parity with the walk's filter.
633634
roleName := strings.ToLower(c.roleName(ctx, conn, ref))
634-
635-
if _, ok := roleSet[roleName]; !ok {
635+
if !candidateRole(roleName, roleSet) {
636636
return nil
637637
}
638638

639+
// The match rule cannot tell editable text from read-only text, so read
640+
// the element's state to tell them apart.
641+
if roleName == atspiRoleText {
642+
states, ok := c.states(ctx, conn, ref)
643+
if !ok {
644+
return nil
645+
}
646+
647+
roleName = effectiveRole(roleName, states)
648+
if _, ok := roleSet[roleName]; !ok {
649+
return nil
650+
}
651+
}
652+
639653
rect, valid := c.extents(ctx, conn, ref)
640654
if !valid {
641655
return nil
@@ -754,6 +768,30 @@ func (c *Client) states(ctx context.Context, conn *dbus.Conn, ref accRef) ([]uin
754768
return states, true
755769
}
756770

771+
// showingRole returns the element's role read through effectiveRole, and
772+
// whether the element is showing with that role in roles.
773+
func (c *Client) showingRole(
774+
ctx context.Context,
775+
conn *dbus.Conn,
776+
ref accRef,
777+
roles map[string]struct{},
778+
roleName string,
779+
) (string, bool) {
780+
if !candidateRole(roleName, roles) {
781+
return roleName, false
782+
}
783+
784+
states, read := c.states(ctx, conn, ref)
785+
if !read || !atspiStateBit(states, atspiStateShowing) {
786+
return roleName, false
787+
}
788+
789+
roleName = effectiveRole(roleName, states)
790+
_, wanted := roles[roleName]
791+
792+
return roleName, wanted
793+
}
794+
757795
// atspiStateBit reports whether the given AT-SPI state bit is set in a state
758796
// bitfield returned by GetState.
759797
func atspiStateBit(states []uint32, bit uint) bool {
@@ -765,16 +803,6 @@ func atspiStateBit(states []uint32, bit uint) bool {
765803
return states[word]&(1<<(bit%atspiBitsPerWord)) != 0
766804
}
767805

768-
// stateHas reports whether the accessible has the given AT-SPI state bit set.
769-
func (c *Client) stateHas(ctx context.Context, conn *dbus.Conn, ref accRef, bit uint) bool {
770-
states, ok := c.states(ctx, conn, ref)
771-
if !ok {
772-
return false
773-
}
774-
775-
return atspiStateBit(states, bit)
776-
}
777-
778806
// frameStates reads a frame's ACTIVE and SHOWING state from a single GetState
779807
// call, halving the per-frame state round-trips during frame selection.
780808
// The two results are the ACTIVE and SHOWING states, in that order.
@@ -838,10 +866,13 @@ func (c *Client) walk(
838866
// Match the AT-SPI role name against the requested set. Both sides are
839867
// native AT-SPI names: configured roles are resolved into this vocabulary
840868
// before they reach the client, and the node carries the native name
841-
// downstream so Adapter.MatchesFilter compares like with like.
842-
roleName := strings.ToLower(c.roleName(ctx, conn, ref))
869+
// downstream so Adapter.MatchesFilter compares like with like. A single
870+
// state read tells whether the element is showing and, for text, whether
871+
// it is editable.
872+
roleName, matched := c.showingRole(ctx, conn, ref, roles,
873+
strings.ToLower(c.roleName(ctx, conn, ref)))
843874

844-
if _, ok := roles[roleName]; ok && c.stateHas(ctx, conn, ref, atspiStateShowing) {
875+
if matched {
845876
if rect, valid := c.extents(ctx, conn, ref); valid {
846877
// AT-SPI reports window-relative coords on Wayland; offset by the
847878
// focused window's screen origin from the KWin bridge.
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
//go:build integration && linux
2+
3+
package atspi
4+
5+
import (
6+
"bufio"
7+
"context"
8+
"errors"
9+
"maps"
10+
"os/exec"
11+
"path/filepath"
12+
"strings"
13+
"testing"
14+
"time"
15+
16+
"github.com/godbus/dbus/v5"
17+
"go.uber.org/zap"
18+
19+
"github.com/y3owk1n/neru/internal/adapter/accessibility/ax"
20+
)
21+
22+
// Both scan paths run against a fake application exported on a private
23+
// dbus-daemon, so the state reads and the editable-text rule are checked in
24+
// the walk and the Collection path themselves.
25+
26+
// errStateUnavailable is what a fake element whose state read fails returns.
27+
var errStateUnavailable = errors.New("state unavailable")
28+
29+
// fakeAccessible answers the AT-SPI calls a scan makes for one element.
30+
type fakeAccessible struct {
31+
ref accRef
32+
role string
33+
states []uint32
34+
stateFails bool
35+
children []*fakeAccessible
36+
}
37+
38+
func (f *fakeAccessible) GetRoleName() (string, *dbus.Error) { return f.role, nil }
39+
40+
func (f *fakeAccessible) GetState() ([]uint32, *dbus.Error) {
41+
if f.stateFails {
42+
return nil, dbus.MakeFailedError(errStateUnavailable)
43+
}
44+
45+
return f.states, nil
46+
}
47+
48+
func (f *fakeAccessible) GetChildren() ([]accRef, *dbus.Error) {
49+
refs := make([]accRef, 0, len(f.children))
50+
for _, child := range f.children {
51+
refs = append(refs, child.ref)
52+
}
53+
54+
return refs, nil
55+
}
56+
57+
func (f *fakeAccessible) GetExtents(uint32) (atspiExtents, *dbus.Error) {
58+
return atspiExtents{W: 10, H: 10}, nil
59+
}
60+
61+
// GetMatches answers the Collection query as a toolkit does: the children
62+
// whose role is in the rule and that are showing.
63+
func (f *fakeAccessible) GetMatches(
64+
rule atspiMatchRule,
65+
_ uint32,
66+
_ int32,
67+
_ bool,
68+
) ([]accRef, *dbus.Error) {
69+
var refs []accRef
70+
71+
for _, child := range f.children {
72+
roleID := atspiRoleNameToID[child.role]
73+
inRule := rule.Roles[roleID/atspiBitsPerWord]&(1<<(roleID%atspiBitsPerWord)) != 0
74+
75+
if inRule && atspiStateBit(child.states, atspiStateShowing) {
76+
refs = append(refs, child.ref)
77+
}
78+
}
79+
80+
return refs, nil
81+
}
82+
83+
func TestClient_ScanPaths_ReadEditableTextAsEntry(t *testing.T) {
84+
address := startPrivateBus(t)
85+
86+
app := connectBus(t, address)
87+
scanner := connectBus(t, address)
88+
89+
busName := app.Names()[0]
90+
child := func(name, role string, states ...uint) *fakeAccessible {
91+
return &fakeAccessible{
92+
ref: accRef{Name: busName, Path: dbus.ObjectPath("/fixture/" + name)},
93+
role: role,
94+
states: stateBitsFor(states...),
95+
}
96+
}
97+
98+
stateFails := child("state_fails", atspiRoleText, atspiStateShowing, atspiStateEditable)
99+
stateFails.stateFails = true
100+
101+
frame := child("frame", "frame", atspiStateShowing)
102+
frame.children = []*fakeAccessible{
103+
child("editable", atspiRoleText, atspiStateShowing, atspiStateEditable),
104+
child("read_only", atspiRoleText, atspiStateShowing),
105+
child("hidden", atspiRoleText, atspiStateEditable),
106+
stateFails,
107+
child("button", "push button", atspiStateShowing),
108+
}
109+
110+
for _, fake := range append([]*fakeAccessible{frame}, frame.children...) {
111+
for _, iface := range []string{atspiAccessibleIfc, atspiComponentIfc, atspiCollectionIfc} {
112+
err := app.Export(fake, fake.ref.Path, iface)
113+
if err != nil {
114+
t.Fatalf("export %s on %s: %v", iface, fake.ref.Path, err)
115+
}
116+
}
117+
}
118+
119+
scans := map[string]func(*Client, map[string]struct{}) []ax.Node{
120+
"walk": func(client *Client, roles map[string]struct{}) []ax.Node {
121+
var out []ax.Node
122+
123+
visited := 0
124+
client.walk(t.Context(), scanner, frame.ref, roles, 0, &out, &visited, 0, 0)
125+
126+
return out
127+
},
128+
"collection": func(client *Client, roles map[string]struct{}) []ax.Node {
129+
out, ok := client.collectViaCollection(t.Context(), scanner, frame.ref, roles, 0, 0)
130+
if !ok {
131+
t.Fatal("the Collection path fell back to the walk")
132+
}
133+
134+
return out
135+
},
136+
}
137+
138+
tests := []struct {
139+
roles []string
140+
want map[string]string
141+
}{
142+
{
143+
roles: []string{"text_field", "button"},
144+
want: map[string]string{"editable": atspiRoleEntry, "button": "push button"},
145+
},
146+
{
147+
roles: []string{"static_text"},
148+
want: map[string]string{"read_only": atspiRoleText},
149+
},
150+
}
151+
152+
for path, scan := range scans {
153+
for _, test := range tests {
154+
t.Run(path+" "+strings.Join(test.roles, " "), func(t *testing.T) {
155+
roles := rolesSet(resolveLinuxRoles(t, test.roles...))
156+
157+
got := make(map[string]string)
158+
for _, node := range scan(New(zap.NewNop(), nil), roles) {
159+
got[strings.TrimPrefix(strings.Split(node.ID(), "@")[0], "/fixture/")] = node.Role()
160+
}
161+
162+
if !maps.Equal(got, test.want) {
163+
t.Errorf("hinted %v, want %v", got, test.want)
164+
}
165+
})
166+
}
167+
}
168+
}
169+
170+
// startPrivateBus runs a dbus-daemon for this test alone and returns its
171+
// address.
172+
func startPrivateBus(t *testing.T) string {
173+
t.Helper()
174+
175+
daemon, err := exec.LookPath("dbus-daemon")
176+
if err != nil {
177+
t.Skip("skipping: dbus-daemon is not installed")
178+
}
179+
180+
ctx, cancel := context.WithCancel(context.Background())
181+
182+
cmd := exec.CommandContext(ctx, daemon, "--session", "--nofork", "--print-address=1",
183+
"--address=unix:path="+filepath.Join(t.TempDir(), "bus"))
184+
185+
stdout, err := cmd.StdoutPipe()
186+
if err != nil {
187+
t.Fatalf("dbus-daemon stdout: %v", err)
188+
}
189+
190+
err = cmd.Start()
191+
if err != nil {
192+
t.Fatalf("start dbus-daemon: %v", err)
193+
}
194+
195+
t.Cleanup(func() {
196+
cancel()
197+
198+
_ = cmd.Wait()
199+
})
200+
201+
lines := make(chan string, 1)
202+
203+
go func() {
204+
line, _ := bufio.NewReader(stdout).ReadString('\n')
205+
lines <- strings.TrimSpace(line)
206+
}()
207+
208+
select {
209+
case address := <-lines:
210+
if address == "" {
211+
t.Fatal("dbus-daemon printed no address")
212+
}
213+
214+
return address
215+
case <-time.After(5 * time.Second):
216+
t.Fatal("dbus-daemon did not print its address within 5s")
217+
}
218+
219+
return ""
220+
}
221+
222+
func connectBus(t *testing.T, address string) *dbus.Conn {
223+
t.Helper()
224+
225+
conn, err := dbus.Connect(address)
226+
if err != nil {
227+
t.Fatalf("connect to the private bus: %v", err)
228+
}
229+
230+
t.Cleanup(func() { _ = conn.Close() })
231+
232+
return conn
233+
}
234+
235+
// stateBitsFor packs AT-SPI state indices the way GetState returns them.
236+
func stateBitsFor(states ...uint) []uint32 {
237+
words := make([]uint32, atspiStateWords)
238+
for _, state := range states {
239+
words[state/atspiBitsPerWord] |= 1 << (state % atspiBitsPerWord)
240+
}
241+
242+
return words
243+
}

‎internal/adapter/accessibility/atspi/client_test.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,15 @@ func TestDeriveTargetRoleIDs(t *testing.T) {
345345
}
346346
})
347347

348+
t.Run("text_field also fetches text, which GTK 3 entries report", func(t *testing.T) {
349+
ids := deriveTargetRoleIDs(rolesSet(resolveLinuxRoles(t, "text_field")))
350+
if !slices.Contains(ids, atspiRoleNameToID[atspiRoleText]) {
351+
t.Errorf("text_field -> %v, want the text role id among them", ids)
352+
}
353+
354+
noDuplicates(t, ids)
355+
})
356+
348357
t.Run("multiple semantic roles union their ids", func(t *testing.T) {
349358
ids := deriveTargetRoleIDs(rolesSet(resolveLinuxRoles(t, "list_item", "row", "button")))
350359
for _, want := range []int32{32, 90, 43, 62} { // list item, table row, push/toggle button

0 commit comments

Comments
 (0)