feat(app): turn on AccessKit, and name the controls drawn as icons

eframe now builds with its accesskit feature, so the widget tree reaches
the platform screen reader. Buttons drawn as an icon glyph were read as
the icon font's private code point, and the hand-painted ones (play,
poster tiles, the watched mark, download rings, the seek bar) as
nothing. A Named trait gives them a name, and hint() makes the existing
hover text that name.
This commit is contained in:
nvrl committed 2026-10-13 16:29:36 +02:00
1 parent d43da778b6
commit 31bdd75559
12 files changed
+401 -26

No files matched your search

+2 -1
View File
@@ -14,6 +14,7 @@ use crate::app::Action;
use crate::engine::AccountStatus;
use crate::theme;
use crate::ui;
use crate::ui::widgets::Named;
/// What the account section holds between frames.
#[derive(Default)]
@@ -379,7 +380,7 @@ pub fn browser(
if !browser.trail.is_empty()
&& ui
.button(egui_phosphor::regular::ARROW_LEFT)
.on_hover_text("Up a folder")
.hint("Up a folder")
.clicked()
{
browser.up(actions);
+2 -1
View File
@@ -10,6 +10,7 @@ use pstr_core::library::{Episode, Library, Title, TitleKind};
use crate::app::{Action, Page};
use crate::theme;
use crate::ui::widgets::Named;
use crate::ui::{self, Art};
const STILL: egui::Vec2 = egui::vec2(128.0, 72.0);
@@ -161,7 +162,7 @@ fn row(
)
.frame(false),
)
.on_hover_text(if episode.is_watched() {
.hint(if episode.is_watched() {
"Remove from history — marks it unwatched"
} else {
"Remove from history — forgets where you stopped"
+10 -2
View File
@@ -315,8 +315,16 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Actio
}
ui.add_space(theme::space::S);
let mut grouped = view.grouped;
if ui
.toggle_value(&mut grouped, egui_phosphor::regular::STACK)
let toggle = ui.toggle_value(&mut grouped, egui_phosphor::regular::STACK);
toggle.widget_info(|| {
egui::WidgetInfo::selected(
egui::WidgetType::SelectableLabel,
true,
view.grouped,
"Group franchises",
)
});
if toggle
.on_hover_text(if view.grouped {
"One tile per franchise. Click to show every title"
} else {
+8 -1
View File
@@ -25,6 +25,7 @@ use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecor
use crate::engine::{Engine, ImageCache, WIDE_MAX_EDGE, thumbnail_key};
use crate::theme;
use widgets::Named;
/// Everything the pages need to draw one title's picture.
///
@@ -324,7 +325,7 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
);
if response
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text("Clear (Esc)")
.hint("Clear (Esc)")
.clicked()
{
search.clear();
@@ -529,6 +530,7 @@ fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Respo
);
let (rect, response) = ui.allocate_exact_size(size, Sense::click());
let response = response.named(text);
if !ui.is_rect_visible(rect) {
return response;
}
@@ -626,6 +628,11 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
let (rect, response) =
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
let response = if card.subtitle.is_empty() {
response.named(card.name)
} else {
response.named(format!("{}, {}", card.name, card.subtitle))
};
// Reached with the arrow keys or Tab: bring it into view, before the
// early return below would skip a tile that is just off screen.
if response.gained_focus() {
+6 -5
View File
@@ -20,6 +20,7 @@ use egui::{Align2, Color32, CornerRadius, Rect, Sense, Vec2};
use crate::app::{Action, Adjacent};
use crate::playback::{Command, Playback};
use crate::theme;
use crate::ui::widgets::Named;
use crate::ui::{self, transport::Neighbours};
/// How long the controls stay up after the pointer stops moving.
@@ -586,7 +587,7 @@ fn up_next_card(
theme::Role::Body.font(),
Color32::WHITE,
);
if response.on_hover_text("Play now").clicked() {
if response.hint("Play now").clicked() {
actions.push(Action::PlayAdjacent(Adjacent::Next));
}
@@ -746,7 +747,7 @@ fn chrome(
.corner_radius(CornerRadius::same(theme::radius::MD))
.min_size(Vec2::new(38.0, 32.0)),
)
.on_hover_text(if fullscreen {
.hint(if fullscreen {
"Leave fullscreen (F)"
} else {
"Fullscreen (F)"
@@ -778,7 +779,7 @@ fn chrome(
.corner_radius(CornerRadius::same(theme::radius::MD))
.min_size(Vec2::new(38.0, 32.0)),
)
.on_hover_text("Episodes (E)")
.hint("Episodes (E)")
.clicked()
{
actions.push(Action::ToggleEpisodes);
@@ -798,7 +799,7 @@ fn chrome(
.corner_radius(CornerRadius::same(theme::radius::MD))
.min_size(Vec2::new(38.0, 32.0)),
)
.on_hover_text("Stop")
.hint("Stop")
.clicked()
{
actions.push(Action::Player(Command::Stop));
@@ -904,7 +905,7 @@ fn episode_drawer(
)
.frame(false),
)
.on_hover_text("Close (E)")
.hint("Close (E)")
.clicked()
{
actions.push(Action::ToggleEpisodes);
+4 -2
View File
@@ -11,9 +11,10 @@ use pstr_core::prefs::PlaybackPrefs;
use pstr_player::LANGUAGES;
use crate::app::Action;
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs};
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs, TEXT_SIZE_CHOICES};
use crate::theme;
use crate::ui;
use crate::ui::widgets::Named;
/// The settings that are a single value rather than a form.
///
@@ -79,6 +80,7 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
ui.horizontal_wrapped(|ui| {
for flavor in Flavor::ALL {
if palette_card(ui, appearance, flavor)
.named(flavor.label())
.on_hover_text(flavor.description())
.clicked()
&& appearance.flavor != flavor
@@ -269,7 +271,7 @@ fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Re
}
}
response
.on_hover_text(accent.label())
.hint(accent.label())
.on_hover_cursor(egui::CursorIcon::PointingHand)
}
+2
View File
@@ -8,6 +8,7 @@ use crate::app::{Action, Page};
use crate::engine::{DownloadItem, DownloadKey, DownloadState};
use crate::playback::PlaybackTarget;
use crate::theme;
use crate::ui::widgets::Named;
use crate::ui::{self, Art, Card};
pub struct OfflineView<'a> {
@@ -878,6 +879,7 @@ pub(crate) fn still(
actions: &mut Vec<Action>,
) {
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
let response = response.named(format!("Play {}", episode.label()));
if !ui.is_rect_visible(rect) {
return;
}
+2 -1
View File
@@ -9,6 +9,7 @@ use egui::{Align2, Color32, CornerRadius, Sense, Stroke, Vec2};
use crate::app::Action;
use crate::theme;
use crate::ui::widgets::Named;
/// How long a message stays up.
const SECONDS: f64 = 5.0;
@@ -214,7 +215,7 @@ fn card(ui: &mut egui::Ui, toast: &Toast) -> Pressed {
[c + Vec2::new(-d, d), c + Vec2::new(d, -d)],
Stroke::new(1.5, ink),
);
if response.on_hover_text("Dismiss").clicked() {
if response.hint("Dismiss").clicked() {
pressed = Pressed::Close;
}
if let Some((label, _)) = &toast.action {
+12 -4
View File
@@ -17,6 +17,7 @@ use crate::app::{Action, Adjacent, Page};
use crate::playback::{Command, Playback};
use crate::theme;
use crate::ui;
use crate::ui::widgets::Named;
/// Height of the clickable seek bar. Comfortably larger than the few pixels it
/// draws, because a seek bar you have to aim at is a seek bar nobody uses.
@@ -163,7 +164,7 @@ pub fn mini(
egui::StrokeKind::Inside,
);
}
if response.on_hover_text("Show the picture again").clicked() {
if response.hint("Show the picture again").clicked() {
actions.push(Action::Goto(Page::Player));
}
}
@@ -313,7 +314,7 @@ fn play_pause(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>,
}
if response
.on_hover_text(if playback.paused {
.hint(if playback.paused {
"Play (Space)"
} else {
"Pause (Space)"
@@ -354,7 +355,7 @@ fn step_button(
enabled,
egui::Button::new(theme::Role::Subhead.rich(label)).min_size(Vec2::new(38.0, 30.0)),
);
if response.on_hover_text(hover).clicked() {
if response.hint(hover).clicked() {
push(actions);
}
}
@@ -436,7 +437,7 @@ fn volume(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
};
if ui
.button(theme::Role::Subhead.rich(icon))
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume))
.hint(format!("Mute (M) — {:.0}%", playback.volume))
.clicked()
{
actions.push(Action::ToggleMute);
@@ -567,6 +568,13 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
let width = ui.available_width();
let (rect, response) =
ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag());
response.widget_info(|| {
egui::WidgetInfo::slider(
playback.duration.is_some(),
playback.position,
format!("Position, {}", ui::format_time(playback.position)),
)
});
let active = response.hovered() || response.dragged();
// Grows rather than jumps when the pointer arrives.
+34
View File
@@ -8,6 +8,31 @@ use egui::{Color32, CornerRadius, Sense, Stroke, Vec2};
use crate::theme;
/// A name for a screen reader, on a control whose face does not say it.
///
/// A button drawn as an icon glyph is announced as the icon font's private
/// code point, and one painted by hand as nothing at all.
pub trait Named {
/// The control's name to a screen reader.
fn named(self, name: impl Into<String>) -> Self;
/// Hover text that is also the control's name.
fn hint(self, text: impl Into<String>) -> Self;
}
impl Named for egui::Response {
fn named(self, name: impl Into<String>) -> Self {
let name = name.into();
let enabled = self.enabled();
self.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &name));
self
}
fn hint(self, text: impl Into<String>) -> Self {
let text = text.into();
self.named(text.clone()).on_hover_text(text)
}
}
/// An on/off switch with its label beside it, for settings.
///
/// Replaces egui's checkbox, whose unchecked box is drawn in the card colour —
@@ -98,6 +123,9 @@ pub fn toggle(ui: &mut egui::Ui, on: &mut bool, text: &str) -> egui::Response {
pub fn watched_mark(ui: &mut egui::Ui, watched: bool) -> egui::Response {
const SIZE: f32 = 20.0;
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
response.widget_info(|| {
egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, watched, "Watched")
});
if ui.is_rect_visible(rect) {
let ctx = ui.ctx();
let t = ctx.animate_bool_with_time(response.id, watched, theme::motion::STATE);
@@ -278,6 +306,12 @@ pub enum DownloadGlyph {
pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Response {
const SIZE: f32 = 30.0;
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
let response = response.named(match glyph {
DownloadGlyph::Download => "Download".to_owned(),
DownloadGlyph::Progress(done) => format!("Downloading, {:.0}%", done * 100.0),
DownloadGlyph::Paused(done) => format!("Download paused, {:.0}%", done * 100.0),
DownloadGlyph::Done => "Downloaded".to_owned(),
});
if !ui.is_rect_visible(rect) {
return response;
}