pages: Add Accessibility/Magnifier page

This commit is contained in:
Victoria Brekenfeld 2025-02-19 19:43:43 +01:00 committed by Michael Murphy
parent 186698ff5b
commit 8e7ed01fe6
13 changed files with 773 additions and 1 deletions

View file

@ -21,6 +21,7 @@ cosmic-config.workspace = true
cosmic-dbus-networkmanager = { git = "https://github.com/pop-os/dbus-settings-bindings", optional = true }
cosmic-idle-config.workspace = true
cosmic-panel-config = { workspace = true, optional = true }
cosmic-protocols = { git = "https://github.com/pop-os/cosmic-protocols", optional = true }
cosmic-randr-shell.workspace = true
cosmic-randr = { workspace = true, optional = true }
cosmic-settings-config = { git = "https://github.com/pop-os/cosmic-settings-daemon", optional = true }
@ -55,6 +56,7 @@ once_cell = "1.20.3"
regex = "1.11.1"
ron = "0.8"
rust-embed = "8.5.0"
sctk = { workspace = true, optional = true }
secure-string = "0.3.0"
serde = { version = "1.0.217", features = ["derive"] }
slab = "0.4.9"
@ -110,6 +112,7 @@ gettext = ["dep:gettext-rs"]
# Default features for Linux
linux = [
"page-accessibility",
"page-about",
"page-bluetooth",
"page-date",
@ -127,6 +130,7 @@ linux = [
]
# Pages
page-accessibility = ["dep:cosmic-protocols", "dep:sctk"]
page-about = ["dep:cosmic-settings-system", "dep:hostname1-zbus", "dep:zbus"]
page-bluetooth = ["dep:bluez-zbus", "dep:zbus", "dep:cosmic-settings-subscriptions"]
page-date = ["dep:timedate-zbus", "dep:zbus"]

View file

@ -2,6 +2,8 @@
// SPDX-License-Identifier: GPL-3.0-only
use crate::config::Config;
#[cfg(feature = "page-accessibility")]
use crate::pages::accessibility;
#[cfg(feature = "page-bluetooth")]
use crate::pages::bluetooth;
use crate::pages::desktop::{self, appearance};
@ -74,6 +76,12 @@ pub struct SettingsApp {
impl SettingsApp {
fn subtask_to_page(&self, cmd: &PageCommands) -> Option<Entity> {
match cmd {
#[cfg(feature = "page-accessibility")]
PageCommands::Accessibility => self.pages.page_id::<accessibility::Page>(),
#[cfg(feature = "page-accessibility")]
PageCommands::AccessibilityMagnifier => {
self.pages.page_id::<accessibility::magnifier::Page>()
}
#[cfg(feature = "page-about")]
PageCommands::About => self.pages.page_id::<system::about::Page>(),
PageCommands::Appearance => self.pages.page_id::<desktop::appearance::Page>(),
@ -194,6 +202,8 @@ impl cosmic::Application for SettingsApp {
app.insert_page::<networking::Page>();
#[cfg(feature = "page-bluetooth")]
app.insert_page::<bluetooth::Page>();
#[cfg(feature = "page-accessibility")]
app.insert_page::<accessibility::Page>();
let desktop_id = app.insert_page::<desktop::Page>().id();
app.insert_page::<display::Page>();
#[cfg(feature = "page-sound")]
@ -368,6 +378,18 @@ impl cosmic::Application for SettingsApp {
}
Message::PageMessage(message) => match message {
#[cfg(feature = "page-accessibility")]
crate::pages::Message::Accessibility(message) => {
if let Some(page) = self.pages.page_mut::<accessibility::Page>() {
return page.update(message).map(Into::into);
}
}
#[cfg(feature = "page-accessibility")]
crate::pages::Message::AccessibilityMagnifier(message) => {
if let Some(page) = self.pages.page_mut::<accessibility::magnifier::Page>() {
return page.update(self.active_page, message).map(Into::into);
}
}
#[cfg(feature = "page-about")]
crate::pages::Message::About(message) => {
page::update!(self.pages, message, system::about::Page);

View file

@ -38,6 +38,12 @@ pub struct Args {
#[derive(Subcommand, Debug, Serialize, Deserialize, Clone)]
pub enum PageCommands {
/// Accessibility settings page
#[cfg(feature = "page-accessibility")]
Accessibility,
/// Accessibility Magnifier settings page
#[cfg(feature = "page-accessibility")]
AccessibilityMagnifier,
/// About settings page
#[cfg(feature = "page-about")]
About,

View file

@ -0,0 +1,339 @@
use std::collections::HashSet;
use cosmic::{
iced::{Element, Length},
iced_core::text::Wrapping,
widget::{self, icon, settings, svg, text},
Apply,
};
use cosmic_comp_config::{ZoomConfig, ZoomMovement};
use cosmic_config::{ConfigGet, ConfigSet};
use cosmic_settings_page::{
self as page,
section::{self, Section},
Entity,
};
use slotmap::SlotMap;
use tokio::sync::mpsc;
use tracing::error;
use super::{wayland, AccessibilityEvent, AccessibilityRequest};
#[derive(Debug)]
pub struct Page {
entity: Entity,
accessibility_config: cosmic_config::Config,
zoom_config: ZoomConfig,
increment_values: Vec<String>,
increment_idx: Option<usize>,
wayland_thread: Option<wayland::Sender>,
magnifier_state: bool,
}
#[derive(Debug, Clone)]
pub enum Message {
Event(wayland::AccessibilityEvent),
ProtocolUnavailable,
SetMagnifier(bool),
SetIncrement(usize),
SetSignin(bool),
SetMovement(ZoomMovement),
}
impl Default for Page {
fn default() -> Self {
let comp_config = cosmic_config::Config::new("com.system76.CosmicComp", 1).unwrap();
let zoom_config: ZoomConfig = comp_config
.get("accessibility_zoom")
.inspect_err(|err| {
if err.is_err() {
error!(?err, "Failed to read config 'accessibility_zoom'");
}
})
.unwrap_or_default();
let mut values = HashSet::<u32>::from_iter([25, 50, 100, 150, 200, zoom_config.increment])
.into_iter()
.collect::<Vec<_>>();
values.sort();
let increment_values = values
.into_iter()
.map(|val| {
format!(
"{}%{}",
val,
if val == ZoomConfig::default().increment {
" (Default)"
} else {
""
}
)
})
.collect::<Vec<_>>();
let increment_idx = increment_values.iter().position(|s| {
s.split("%").next().and_then(|val| str::parse(val).ok()) == Some(zoom_config.increment)
});
Page {
entity: Entity::default(),
accessibility_config: comp_config,
zoom_config,
increment_values,
increment_idx,
wayland_thread: None,
magnifier_state: false,
}
}
}
impl page::Page<crate::pages::Message> for Page {
fn set_id(&mut self, entity: Entity) {
self.entity = entity;
}
fn info(&self) -> page::Info {
page::Info::new(
"accessibility_magnifier",
"preferences-desktop-accessibility",
)
.title(fl!("magnifier"))
}
fn content(
&self,
sections: &mut SlotMap<section::Entity, page::Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![
sections.insert(magnifier()),
sections.insert(tip()),
sections.insert(view_movement()),
])
}
fn on_enter(
&mut self,
sender: mpsc::Sender<crate::pages::Message>,
) -> cosmic::Task<crate::pages::Message> {
if self.wayland_thread.is_none() {
match wayland::spawn_wayland_connection() {
Ok((tx, mut rx)) => {
self.wayland_thread = Some(tx);
tokio::task::spawn(async move {
while let Some(event) = rx.recv().await {
let _ = sender
.send(crate::pages::Message::AccessibilityMagnifier(
Message::Event(event),
))
.await;
}
let _ = sender
.send(crate::pages::Message::AccessibilityMagnifier(
Message::ProtocolUnavailable,
))
.await;
});
}
Err(err) => {
tracing::warn!(
"Failed to spawn wayland connection for magnifier page: {}",
err
);
return cosmic::Task::done(crate::pages::Message::Accessibility(
super::Message::Return,
));
}
}
}
cosmic::Task::none()
}
fn on_leave(&mut self) -> cosmic::Task<crate::pages::Message> {
let _ = self.wayland_thread.take();
cosmic::Task::none()
}
}
impl page::AutoBind<crate::pages::Message> for Page {}
pub fn magnifier() -> section::Section<crate::pages::Message> {
crate::slab!(descriptions {
magnifier = fl!("magnifier");
controls = fl!("magnifier", "controls");
increment = fl!("magnifier", "increment");
signin = fl!("magnifier", "signin");
});
Section::default()
.title(&descriptions[magnifier])
.descriptions(descriptions)
.view::<Page>(move |_binder, page, section| {
let descriptions = &section.descriptions;
settings::section()
.title(&section.title)
.add(
settings::item::builder(&descriptions[magnifier])
.description(&descriptions[controls])
.control(
widget::toggler(page.magnifier_state).on_toggle(Message::SetMagnifier),
),
)
.add(settings::item(
&descriptions[increment],
widget::dropdown(
&page.increment_values,
page.increment_idx,
Message::SetIncrement,
),
))
.add(settings::item(
&descriptions[signin],
widget::toggler(page.zoom_config.start_on_login).on_toggle(Message::SetSignin),
))
.apply(Element::from)
.map(crate::pages::Message::AccessibilityMagnifier)
})
}
pub fn tip() -> section::Section<crate::pages::Message> {
crate::slab!(descriptions {
applet = fl!("magnifier", "applet");
});
let applet_illustration = icon::from_name("illustration-accessibility-magnifier-applet")
.icon()
.into_svg_handle();
Section::default()
.descriptions(descriptions)
.view::<Page>(move |_binder, _page, section| {
let descriptions = &section.descriptions;
let mut items = vec![text::body(&descriptions[applet])
.wrapping(Wrapping::Word)
.width(Length::Shrink)
.into()];
if let Some(illustration) = applet_illustration.clone() {
items.push(svg(illustration).width(Length::Fill).into());
}
settings::section()
.add(settings::flex_item_row(items))
.into()
})
}
pub fn view_movement() -> section::Section<crate::pages::Message> {
crate::slab!(descriptions {
movement = fl!("magnifier", "movement");
continuous = fl!("magnifier", "continuous");
onedge = fl!("magnifier", "onedge");
centered = fl!("magnifier", "centered");
});
Section::default()
.title(&descriptions[movement])
.descriptions(descriptions)
.view::<Page>(move |_binder, page, section| {
let descriptions = &section.descriptions;
settings::section()
.title(&section.title)
.add(widget::settings::item_row(vec![widget::radio(
text::body(&descriptions[continuous]),
ZoomMovement::Continuously,
Some(page.zoom_config.view_moves),
Message::SetMovement,
)
.width(Length::Fill)
.into()]))
.add(widget::settings::item_row(vec![widget::radio(
text::body(&descriptions[onedge]),
ZoomMovement::OnEdge,
Some(page.zoom_config.view_moves),
Message::SetMovement,
)
.width(Length::Fill)
.into()]))
.add(widget::settings::item_row(vec![widget::radio(
text::body(&descriptions[centered]),
ZoomMovement::Centered,
Some(page.zoom_config.view_moves),
Message::SetMovement,
)
.width(Length::Fill)
.into()]))
.apply(Element::from)
.map(crate::pages::Message::AccessibilityMagnifier)
})
}
impl Page {
pub fn update(
&mut self,
active_page: page::Entity,
message: Message,
) -> cosmic::iced::Task<crate::app::Message> {
match message {
Message::Event(AccessibilityEvent::Magnifier(value)) => {
self.magnifier_state = value;
}
Message::SetMagnifier(value) => {
if let Some(sender) = self.wayland_thread.as_ref() {
let _ = sender.send(AccessibilityRequest::Magnifier(value));
}
}
Message::SetIncrement(idx) => {
self.increment_idx = Some(idx);
let value = self.increment_values[idx]
.split("%")
.next()
.unwrap()
.parse::<u32>()
.unwrap();
self.zoom_config.increment = value;
if let Err(err) = self
.accessibility_config
.set("accessibility_zoom", self.zoom_config)
{
error!(?err, "Failed to set config 'accessibility_zoom'");
}
}
Message::SetSignin(value) => {
self.zoom_config.start_on_login = value;
if let Err(err) = self
.accessibility_config
.set("accessibility_zoom", self.zoom_config)
{
error!(?err, "Failed to set config 'accessibility_zoom'");
}
}
Message::SetMovement(zoom_movement) => {
self.zoom_config.view_moves = zoom_movement;
if let Err(err) = self
.accessibility_config
.set("accessibility_zoom", self.zoom_config)
{
error!(?err, "Failed to set config 'accessibility_zoom'");
}
}
// We shouldn't have gotten into this page in that case
Message::Event(AccessibilityEvent::Closed) | Message::ProtocolUnavailable => {
if active_page == self.entity {
return cosmic::iced::Task::done(crate::app::Message::PageMessage(
crate::pages::Message::Accessibility(super::Message::Return),
));
}
}
}
cosmic::iced::Task::none()
}
}

View file

@ -0,0 +1,175 @@
use cosmic::{
iced_core::text::Wrapping,
theme,
widget::{button, container, horizontal_space, icon, settings, text},
Apply,
};
pub use cosmic_comp_config::ZoomMovement;
use cosmic_settings_page::{
self as page,
section::{self, Section},
Insert,
};
use slotmap::SlotMap;
use tokio::sync::mpsc;
pub mod magnifier;
mod wayland;
pub use wayland::{AccessibilityEvent, AccessibilityRequest};
#[derive(Debug, Default)]
pub struct Page {
entity: page::Entity,
magnifier_state: bool,
wayland_available: bool,
wayland_thread: Option<wayland::Sender>,
}
#[derive(Debug, Clone)]
pub enum Message {
Event(wayland::AccessibilityEvent),
ProtocolUnavailable,
Return,
}
impl page::Page<crate::pages::Message> for Page {
fn set_id(&mut self, entity: page::Entity) {
self.entity = entity;
}
fn info(&self) -> page::Info {
page::Info::new(
"accessibility",
"preferences-desktop-accessibility-symbolic",
)
.title(fl!("accessibility"))
}
fn content(
&self,
sections: &mut SlotMap<section::Entity, page::Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![sections.insert(vision())])
}
fn on_enter(
&mut self,
sender: mpsc::Sender<crate::pages::Message>,
) -> cosmic::Task<crate::pages::Message> {
if self.wayland_thread.is_none() {
match wayland::spawn_wayland_connection() {
Ok((tx, mut rx)) => {
self.wayland_available = true;
self.wayland_thread = Some(tx);
tokio::task::spawn(async move {
while let Some(event) = rx.recv().await {
let _ = sender
.send(crate::pages::Message::Accessibility(Message::Event(event)))
.await;
}
let _ = sender
.send(crate::pages::Message::Accessibility(
Message::ProtocolUnavailable,
))
.await;
});
}
Err(err) => {
tracing::warn!(
"Failed to spawn wayland connection for accessibility page: {}",
err
);
self.wayland_available = false;
}
}
}
cosmic::Task::none()
}
fn on_leave(&mut self) -> cosmic::Task<crate::pages::Message> {
let _ = self.wayland_thread.take();
cosmic::Task::none()
}
}
impl page::AutoBind<crate::pages::Message> for Page {
fn sub_pages(page: Insert<crate::pages::Message>) -> Insert<crate::pages::Message> {
page.sub_page::<magnifier::Page>()
}
}
pub fn vision() -> section::Section<crate::pages::Message> {
crate::slab!(descriptions {
magnifier = fl!("magnifier");
vision = fl!("accessibility", "vision");
on = fl!("accessibility", "on");
off = fl!("accessibility", "off");
unavailable = fl!("accessibility", "unavailable");
});
Section::default()
.title(&descriptions[vision])
.descriptions(descriptions)
.view::<Page>(move |binder, page, section| {
let descriptions = &section.descriptions;
settings::section()
.title(&section.title)
.add({
let (magnifier_entity, _magnifier_info) = binder
.info
.iter()
.find(|(_, v)| v.id == "accessibility_magnifier")
.expect("magnifier page not found");
let status_text = if page.wayland_available {
if page.magnifier_state {
&descriptions[on]
} else {
&descriptions[off]
}
} else {
&descriptions[unavailable]
};
settings::item_row(vec![
text::body(&descriptions[magnifier])
.wrapping(Wrapping::Word)
.into(),
horizontal_space().into(),
text::body(status_text).wrapping(Wrapping::Word).into(),
icon::from_name("go-next-symbolic").size(16).into(),
])
.apply(container)
.class(cosmic::theme::Container::List)
.apply(button::custom)
.class(theme::Button::Transparent)
.on_press_maybe(
page.wayland_available
.then_some(crate::pages::Message::Page(magnifier_entity)),
)
})
.into()
})
}
impl Page {
pub fn update(&mut self, message: Message) -> cosmic::iced::Task<crate::app::Message> {
match message {
Message::Event(AccessibilityEvent::Magnifier(value)) => {
self.magnifier_state = value;
}
Message::Event(AccessibilityEvent::Closed) | Message::ProtocolUnavailable => {
self.wayland_available = false;
}
Message::Return => {
return cosmic::iced::Task::done(crate::app::Message::Page(self.entity))
}
}
cosmic::iced::Task::none()
}
}

View file

@ -0,0 +1,152 @@
use cosmic_protocols::a11y::v1::client::cosmic_a11y_manager_v1;
use sctk::{
reexports::{
calloop::{self, channel, LoopSignal},
calloop_wayland_source::WaylandSource,
client::{
globals::{registry_queue_init, GlobalListContents},
protocol::wl_registry,
ConnectError, Connection, Dispatch, Proxy,
},
},
registry::RegistryState,
};
use tokio::sync::mpsc;
#[derive(Debug, Clone, Copy)]
pub enum AccessibilityEvent {
Magnifier(bool),
Closed,
}
#[derive(Debug, Clone, Copy)]
pub enum AccessibilityRequest {
Magnifier(bool),
}
pub type Sender = calloop::channel::Sender<AccessibilityRequest>;
pub fn spawn_wayland_connection() -> Result<
(
channel::Sender<AccessibilityRequest>,
mpsc::Receiver<AccessibilityEvent>,
),
ConnectError,
> {
let (event_tx, event_rx) = mpsc::channel(10);
let (request_tx, request_rx) = channel::channel();
let conn = Connection::connect_to_env()?;
std::thread::spawn(move || {
if let Err(err) = wayland_thread(conn, event_tx.clone(), request_rx) {
tracing::warn!("Accessibility protocol wayland thread crashed: {}", err);
let _ = event_tx.send(AccessibilityEvent::Closed);
}
});
Ok((request_tx, event_rx))
}
fn wayland_thread(
conn: Connection,
tx: mpsc::Sender<AccessibilityEvent>,
rx: channel::Channel<AccessibilityRequest>,
) -> anyhow::Result<()> {
struct State {
loop_signal: LoopSignal,
tx: mpsc::Sender<AccessibilityEvent>,
global: cosmic_a11y_manager_v1::CosmicA11yManagerV1,
magnifier: bool,
}
impl Dispatch<cosmic_a11y_manager_v1::CosmicA11yManagerV1, ()> for State {
fn event(
state: &mut Self,
_proxy: &cosmic_a11y_manager_v1::CosmicA11yManagerV1,
event: <cosmic_a11y_manager_v1::CosmicA11yManagerV1 as Proxy>::Event,
_data: &(),
_conn: &Connection,
_qhandle: &sctk::reexports::client::QueueHandle<Self>,
) {
match event {
cosmic_a11y_manager_v1::Event::Magnifier { active } => {
let magnifier = active
.into_result()
.unwrap_or(cosmic_a11y_manager_v1::ActiveState::Disabled)
== cosmic_a11y_manager_v1::ActiveState::Enabled;
if magnifier != state.magnifier {
if state
.tx
.blocking_send(AccessibilityEvent::Magnifier(magnifier))
.is_err()
{
state.loop_signal.stop();
state.loop_signal.wakeup();
};
state.magnifier = magnifier;
}
}
_ => unreachable!(),
}
}
}
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for State {
fn event(
_state: &mut Self,
_proxy: &wl_registry::WlRegistry,
_event: <wl_registry::WlRegistry as Proxy>::Event,
_data: &GlobalListContents,
_conn: &Connection,
_qhandle: &sctk::reexports::client::QueueHandle<Self>,
) {
// We don't care about any dynamic globals
}
}
let mut event_loop = calloop::EventLoop::<State>::try_new().unwrap();
let loop_handle = event_loop.handle();
let (globals, event_queue) = registry_queue_init(&conn).unwrap();
let qhandle = event_queue.handle();
WaylandSource::new(conn, event_queue)
.insert(loop_handle.clone())
.map_err(|err| err.error)?;
let registry_state = RegistryState::new(&globals);
let Ok(global) = registry_state.bind_one::<cosmic_a11y_manager_v1::CosmicA11yManagerV1, _, _>(
&qhandle,
1..=1,
(),
) else {
return Ok(());
};
loop_handle
.insert_source(rx, |request, _, state| match request {
channel::Event::Msg(AccessibilityRequest::Magnifier(val)) => {
state.global.set_magnifier(if val {
cosmic_a11y_manager_v1::ActiveState::Enabled
} else {
cosmic_a11y_manager_v1::ActiveState::Disabled
});
}
channel::Event::Closed => {
state.loop_signal.stop();
state.loop_signal.wakeup();
}
})
.map_err(|err| err.error)?;
let mut state = State {
loop_signal: event_loop.get_signal(),
tx,
global,
magnifier: false,
};
event_loop.run(None, &mut state, |_| {})?;
Ok(())
}

View file

@ -3,6 +3,8 @@
use cosmic_settings_page::Entity;
#[cfg(feature = "page-accessibility")]
pub mod accessibility;
#[cfg(feature = "page-bluetooth")]
pub mod bluetooth;
pub mod desktop;
@ -20,6 +22,10 @@ pub mod time;
#[derive(Clone, Debug)]
pub enum Message {
#[cfg(feature = "page-accessibility")]
Accessibility(accessibility::Message),
#[cfg(feature = "page-accessibility")]
AccessibilityMagnifier(accessibility::magnifier::Message),
#[cfg(feature = "page-about")]
About(system::about::Message),
Appearance(desktop::appearance::Message),