Skip to content

Commit 15d1029

Browse files
sjvrensburgclaude
andcommitted
feat(a11y): set AT-SPI app name + add --page/--zoom/--rail launch flags
- Application.Name = "RailReader2" so the app registers on the a11y bus under its real name instead of the generic "Avalonia Application" (frame is already named railreader2). Needs a live atspi re-check to confirm. - Known-state startup flags for agents / scripted launches: --page <n> (1-based), --zoom <pct> (e.g. 300), --rail. Parsed in App startup (ParseStartupFlags) and applied after the document opens via new MainWindowViewModel.ApplyStartupView (GoToPage + SetZoomPercent + an analysis-poll that forces rail once the page is analysed). - Document both in docs/agent-playbook.md. Build clean (6 projects, 0 warnings). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5e28188 commit 15d1029

3 files changed

Lines changed: 82 additions & 6 deletions

File tree

docs/agent-playbook.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,17 @@ gsettings set org.gnome.desktop.interface toolkit-accessibility true
4646
# Launch on a document (positional arg = PDF path)
4747
railreader2 /path/to/paper.pdf
4848
# or from source: dotnet run -c Release --project src/RailReader2 -- /path/to/paper.pdf
49+
50+
# Known-state startup flags (any combination; applied once the document opens):
51+
railreader2 /path/to/paper.pdf --page 7 --zoom 300 --rail
52+
# --page <n> 1-based page to open on
53+
# --zoom <pct> zoom percentage (e.g. 300 = 300%); clamped to 50–2000
54+
# --rail engage rail mode (forces it at the viewport centre once the page is
55+
# analysed; a --zoom above the rail threshold engages it on its own)
4956
```
5057

51-
**App identity on the a11y bus:** the application registers as **`Avalonia Application`**
52-
(Avalonia doesn't derive it from the assembly); the top-level **frame is named
53-
`railreader2`**. Match on the frame, or on the Avalonia app name. There are no
54-
`--page/--zoom/--rail` startup flags yet — reach the desired state with the actions below.
58+
**App identity on the a11y bus:** the application registers as **`RailReader2`** (set via
59+
`Application.Name`), with the top-level **frame named `railreader2`**. Match on either.
5560

5661
---
5762

@@ -168,7 +173,6 @@ continue when parked) · `J` jump mode · `H` line highlight · `F` line focus d
168173
## 8. Known gaps / caveats (as of 2026-06-27)
169174

170175
- **Menus aren't AT-SPI-actionable** — use buttons + accelerators (§1).
171-
- App name on the bus is the generic `Avalonia Application` (frame is `railreader2`).
172176
- **Input/screencast transport:** AT-SPI (the tree above) is display-server-agnostic, but an
173177
agent's *input injection and screen capture* on Wayland go through the portals
174178
(RemoteDesktop/ScreenCast); on X11 they're direct. This playbook covers the a11y tree, not

src/RailReader2/App.axaml.cs

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ private static bool IsFirstRun()
3030

3131
public override void Initialize()
3232
{
33+
// The accessibility/automation backends surface this as the application's name on the bus
34+
// (AT-SPI on Linux); without it the app registers as the generic "Avalonia Application", which an
35+
// agent/screen reader can't identify. The window frame is named separately (via the window title).
36+
Name = "RailReader2";
3337
AvaloniaXamlLoader.Load(this);
3438
}
3539

@@ -76,12 +80,49 @@ public override void OnFrameworkInitializationCompleted()
7680
// First non-flag argument that names an existing file is the PDF to open.
7781
var docPath = args?.FirstOrDefault(a => !a.StartsWith("--") && File.Exists(a));
7882
if (docPath is not null)
79-
window.Opened += (_, _) => vm.FireAndForget(vm.OpenDocument(docPath), nameof(vm.OpenDocument));
83+
{
84+
// Optional known-state startup flags (for agents / scripted launches):
85+
// --page <n> (1-based) --zoom <percent, e.g. 300> --rail
86+
var (startPage, startZoom, startRail) = ParseStartupFlags(args!);
87+
window.Opened += (_, _) => vm.FireAndForget(OpenStartupDocument(), nameof(vm.OpenDocument));
88+
89+
async System.Threading.Tasks.Task OpenStartupDocument()
90+
{
91+
await vm.OpenDocument(docPath);
92+
if (startPage is not null || startZoom is not null || startRail)
93+
vm.ApplyStartupView(startPage, startZoom, startRail);
94+
}
95+
}
8096

8197
window.Show();
8298
}, DispatcherPriority.Background);
8399
}
84100

85101
base.OnFrameworkInitializationCompleted();
86102
}
103+
104+
/// <summary>Parse the optional known-state startup flags: <c>--page &lt;n&gt;</c> (1-based),
105+
/// <c>--zoom &lt;percent&gt;</c> (e.g. 300 for 300%), and <c>--rail</c>. Unknown/malformed values are
106+
/// ignored; the PDF path is a separate positional argument.</summary>
107+
private static (int? Page, double? Zoom, bool Rail) ParseStartupFlags(string[] args)
108+
{
109+
int? page = null;
110+
double? zoom = null;
111+
bool rail = false;
112+
for (int i = 0; i < args.Length; i++)
113+
{
114+
switch (args[i])
115+
{
116+
case "--page" when i + 1 < args.Length && int.TryParse(args[i + 1], out var p):
117+
page = p; i++; break;
118+
case "--zoom" when i + 1 < args.Length && double.TryParse(
119+
args[i + 1], System.Globalization.NumberStyles.Float,
120+
System.Globalization.CultureInfo.InvariantCulture, out var z):
121+
zoom = z; i++; break;
122+
case "--rail":
123+
rail = true; break;
124+
}
125+
}
126+
return (page, zoom, rail);
127+
}
87128
}

src/RailReader2/ViewModels/MainWindowViewModel.Navigation.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using Avalonia.Threading;
12
using CommunityToolkit.Mvvm.Input;
23
using RailReader.Core;
34
using RailReader.Core.Models;
@@ -191,6 +192,36 @@ public void StartRailHere()
191192
ActivateRailAtClick(ww / 2.0, wh / 2.0);
192193
}
193194

195+
/// <summary>Apply a requested initial view from launch flags (<c>--page</c>/<c>--zoom</c>/<c>--rail</c>),
196+
/// after the startup document has opened. Page is 1-based; zoom is a percentage (e.g. 300 = 300%).
197+
/// Rail engages once the page is analysed: a high enough zoom seats it via the tick, otherwise this
198+
/// forces it at the viewport centre (a short poll waits for analysis, then gives up — no model = no
199+
/// rail).</summary>
200+
public void ApplyStartupView(int? page1Based, double? zoomPercent, bool rail)
201+
{
202+
if (ActiveTab is not { } tab) return;
203+
if (page1Based is { } p)
204+
GoToPage(Math.Clamp(p - 1, 0, Math.Max(0, tab.PageCount - 1)));
205+
if (zoomPercent is { } z)
206+
SetZoomPercent(z);
207+
if (!rail) return;
208+
209+
StartBackgroundAnalysis();
210+
// Rail needs the current page analysed; poll briefly, then force rail if the tick hasn't already
211+
// engaged it (a >threshold zoom would have). Capped so a missing ONNX model can't poll forever.
212+
int attempts = 0;
213+
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(150) };
214+
timer.Tick += (_, _) =>
215+
{
216+
attempts++;
217+
if (ActiveTab is not { } t || attempts > 80) { timer.Stop(); return; }
218+
if (!t.AnalysisCache.ContainsKey(t.State.CurrentPage)) return;
219+
timer.Stop();
220+
if (!t.Rail.Active && !ForcedRailActive) StartRailHere();
221+
};
222+
timer.Start();
223+
}
224+
194225
/// <summary>True while rail is held active below the zoom threshold by a forced "start rail here"
195226
/// activation. Used to drive an Escape that releases it.</summary>
196227
public bool ForcedRailActive => _controller.ForcedRailActive;

0 commit comments

Comments
 (0)