Skip to content

Commit 5d69079

Browse files
brightening-eyesweb-flowclaude
authored
feat(ui): label widgets with .labelled_by for NVDA / Narrator (#1015)
Adds `egui::Id` plumbing through `form_row` so each widget can call `.labelled_by(label_id)` to associate with its visible label. NVDA / Narrator now reads the field name when focus moves to a control, instead of just announcing the control type. AccessKit was already enabled in `Cargo.toml` (`eframe` features), but without the explicit `labelled_by` association, the screen reader had no way to map the text input or combobox to its preceding label. Verified locally on top of v1.9.18 / main: - `cargo build --release --features ui --bin mhrv-rs-ui`: clean - `cargo test --lib --release`: 208/208 Tested by @brightening-eyes (the blind user who originally reported #916) with NVDA — confirmed working. `form_row`'s signature now takes `widget: impl FnOnce(&mut egui::Ui, egui::Id)`. Two existing callers that don't need the label_id (the `Mode` combobox, `Share on LAN` checkbox) ignore it via `_label_id` binding — no functional change there. Closes #916. Reviewed via Anthropic Claude. Co-Authored-By: brightening-eyes <noreply@github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9909b9b commit 5d69079

1 file changed

Lines changed: 48 additions & 22 deletions

File tree

src/bin/ui.rs

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -836,17 +836,18 @@ fn form_row(
836836
ui: &mut egui::Ui,
837837
label: &str,
838838
hover: Option<&str>,
839-
widget: impl FnOnce(&mut egui::Ui),
839+
widget: impl FnOnce(&mut egui::Ui, egui::Id),
840840
) {
841841
ui.horizontal(|ui| {
842842
let resp = ui.add_sized(
843843
[120.0, 20.0],
844844
egui::Label::new(egui::RichText::new(label).color(egui::Color32::from_gray(200))),
845845
);
846+
let label_id = resp.id;
846847
if let Some(h) = hover {
847848
resp.on_hover_text(h);
848849
}
849-
widget(ui);
850+
widget(ui, label_id);
850851
});
851852
}
852853

@@ -935,7 +936,7 @@ impl eframe::App for App {
935936
"apps_script: DPI bypass via Apps Script relay (needs cert).\n\
936937
full: tunnel ALL traffic through Apps Script + tunnel node (no cert needed).\n\
937938
direct: SNI-rewrite tunnel only — no relay (Google edge + any fronting_groups)."
938-
), |ui| {
939+
), |ui, _label_id| {
939940
egui::ComboBox::from_id_source("mode")
940941
.selected_text(match self.form.mode.as_str() {
941942
"direct" | "google_only" => "Direct (no relay)",
@@ -988,11 +989,12 @@ impl eframe::App for App {
988989
form_row(ui, "Deployment IDs", Some(
989990
"One deployment ID per line. Proxy round-robins between them and sidelines \
990991
any ID that hits its daily quota for 10 minutes before retrying."
991-
), |ui| {
992+
), |ui, label_id| {
992993
ui.add(egui::TextEdit::multiline(&mut self.form.script_id)
993994
.hint_text("one deployment ID per line")
994995
.desired_width(f32::INFINITY)
995-
.desired_rows(3));
996+
.desired_rows(3))
997+
.labelled_by(label_id);
996998
});
997999

9981000
let id_count = self.form.script_id
@@ -1014,19 +1016,21 @@ impl eframe::App for App {
10141016

10151017
form_row(ui, "Auth key", Some(
10161018
"Same value as AUTH_KEY inside your Code.gs."
1017-
), |ui| {
1019+
), |ui, label_id| {
10181020
ui.add(egui::TextEdit::singleline(&mut self.form.auth_key)
10191021
.password(!self.form.show_auth_key)
1020-
.desired_width(f32::INFINITY));
1022+
.desired_width(f32::INFINITY))
1023+
.labelled_by(label_id);
10211024
});
10221025
});
10231026
});
10241027

10251028
// ── Section: Network ──────────────────────────────────────────
10261029
section(ui, "Network", |ui| {
1027-
form_row(ui, "Google IP", None, |ui| {
1030+
form_row(ui, "Google IP", None, |ui, label_id| {
10281031
ui.add(egui::TextEdit::singleline(&mut self.form.google_ip)
1029-
.desired_width(f32::INFINITY));
1032+
.desired_width(f32::INFINITY))
1033+
.labelled_by(label_id);
10301034
});
10311035
ui.horizontal(|ui| {
10321036
ui.add_space(120.0 + 8.0);
@@ -1064,9 +1068,10 @@ impl eframe::App for App {
10641068
}
10651069
});
10661070

1067-
form_row(ui, "Front domain", None, |ui| {
1071+
form_row(ui, "Front domain", None, |ui, label_id| {
10681072
ui.add(egui::TextEdit::singleline(&mut self.form.front_domain)
1069-
.desired_width(f32::INFINITY));
1073+
.desired_width(f32::INFINITY))
1074+
.labelled_by(label_id);
10701075
});
10711076

10721077
// Network sharing: phones, tablets, other laptops on the
@@ -1102,7 +1107,7 @@ impl eframe::App for App {
11021107
other devices then point their HTTP / SOCKS5 proxy at the \
11031108
LAN IP shown below. Make sure your firewall lets in the proxy \
11041109
port — macOS pops up a Firewall prompt the first time."
1105-
), |ui| {
1110+
), |ui, _label_id| {
11061111
if is_custom_bind {
11071112
// The user manually wrote a specific bind IP —
11081113
// don't let the checkbox stomp on it. Show what
@@ -1168,11 +1173,15 @@ impl eframe::App for App {
11681173
egui::Label::new(egui::RichText::new("Ports")
11691174
.color(egui::Color32::from_gray(200))),
11701175
);
1171-
ui.label(egui::RichText::new("HTTP").small());
1172-
ui.add(egui::TextEdit::singleline(&mut self.form.listen_port).desired_width(70.0));
1176+
let http_label = ui.label(egui::RichText::new("HTTP").small());
1177+
ui.add(egui::TextEdit::singleline(&mut self.form.listen_port)
1178+
.desired_width(70.0))
1179+
.labelled_by(http_label.id);
11731180
ui.add_space(10.0);
1174-
ui.label(egui::RichText::new("SOCKS5").small());
1175-
ui.add(egui::TextEdit::singleline(&mut self.form.socks5_port).desired_width(70.0));
1181+
let socks_label = ui.label(egui::RichText::new("SOCKS5").small());
1182+
ui.add(egui::TextEdit::singleline(&mut self.form.socks5_port)
1183+
.desired_width(70.0))
1184+
.labelled_by(socks_label.id);
11761185
});
11771186
});
11781187

@@ -1197,23 +1206,24 @@ impl eframe::App for App {
11971206
When set, non-HTTP / raw-TCP traffic (Telegram MTProto, IMAP, SSH, …) \
11981207
is chained through it instead of direct. HTTP/HTTPS still go through \
11991208
the Apps Script relay."
1200-
), |ui| {
1209+
), |ui, label_id| {
12011210
ui.add(egui::TextEdit::singleline(&mut self.form.upstream_socks5)
12021211
.hint_text("empty = direct; 127.0.0.1:50529 for local xray")
1203-
.desired_width(f32::INFINITY));
1212+
.desired_width(f32::INFINITY))
1213+
.labelled_by(label_id);
12041214
});
12051215

12061216
form_row(ui, "Parallel dispatch", Some(
12071217
"Fire N Apps Script IDs in parallel per request and take the first \
12081218
response. 0/1 = off. 2-3 kills long-tail latency at N× quota cost. \
12091219
Only effective with multiple IDs configured."
1210-
), |ui| {
1220+
), |ui, _label_id| {
12111221
ui.add(egui::DragValue::new(&mut self.form.parallel_relay)
12121222
.speed(1)
12131223
.range(0..=8));
12141224
});
12151225

1216-
form_row(ui, "Log level", None, |ui| {
1226+
form_row(ui, "Log level", None, |ui, _label_id| {
12171227
egui::ComboBox::from_id_source("loglevel")
12181228
.selected_text(&self.form.log_level)
12191229
.show_ui(ui, |ui| {
@@ -1965,11 +1975,19 @@ impl App {
19651975
for (i, row) in self.form.sni_pool.iter_mut().enumerate() {
19661976
ui.horizontal(|ui| {
19671977
ui.checkbox(&mut row.enabled, "");
1978+
let sni_label = ui.add_sized(
1979+
[0.0, 0.0],
1980+
egui::Label::new(
1981+
egui::RichText::new(format!("SNI name {}", i))
1982+
.color(egui::Color32::TRANSPARENT),
1983+
),
1984+
);
19681985
ui.add(
19691986
egui::TextEdit::singleline(&mut row.name)
19701987
.desired_width(NAME_W)
19711988
.font(egui::TextStyle::Monospace),
1972-
);
1989+
)
1990+
.labelled_by(sni_label.id);
19731991
let status_txt = match probe_map.get(&row.name) {
19741992
Some(SniProbeState::Ok(ms)) => {
19751993
egui::RichText::new(format!("ok {} ms", ms))
@@ -2024,11 +2042,19 @@ impl App {
20242042

20252043
ui.separator();
20262044
ui.horizontal(|ui| {
2045+
let custom_label = ui.add_sized(
2046+
[0.0, 0.0],
2047+
egui::Label::new(
2048+
egui::RichText::new("Custom SNI")
2049+
.color(egui::Color32::TRANSPARENT),
2050+
),
2051+
);
20272052
ui.add(
20282053
egui::TextEdit::singleline(&mut self.form.sni_custom_input)
20292054
.hint_text("add a custom SNI (e.g. translate.google.com)")
20302055
.desired_width(280.0),
2031-
);
2056+
)
2057+
.labelled_by(custom_label.id);
20322058
let add_clicked = ui.button("+ Add").clicked();
20332059
if add_clicked {
20342060
let new_name = self.form.sni_custom_input.trim().to_string();

0 commit comments

Comments
 (0)