From 8940ec1af6c067b6ccec483b844548ad6e37f041 Mon Sep 17 00:00:00 2001 From: blakewilliams1 Date: Wed, 7 Oct 2026 07:33:35 -0700 Subject: [PATCH] First pass at drawing tablet settings page (#2220) This is a first pass at creating a page dedicated for controlling the settings of tablet/pen style input devices. It's purpose is to modify the config file found at `/home/$USER/.config/cosmic/com.system76.CosmicComp/v1/input_devices`, so that the Compositor can route incoming input events to the correct displays and the events don't get discarded. Here's a video showing the functionality: As this is a first pass at the page, it naturally isn't in a fully featured state. It only is doing the bare minimum to get tablet input to not be discarded. There are 2 limitations I'd like to call out with this current state: **1)** The settings page will only show settings options for tablet/pen style input devices that currently plugged in. It doesn't have ability to allow users to clear out stale config entries for old tablets they might not be using anymore. They'll need to manually delete the entries from `input_devices` with a text editor. **2)** The list of drawing tablets present is calculated once when you open the settings application and doesn't live reload as devices are connected or disconnected. - [X] I have disclosed use of any AI generated code in my commit messages. - If you are using an LLM, and do not fully understand the changes it is making to the code base, do not create a PR. - In our experience, AI generated code often results in overly complex code that lacks enough context for a proper fix or feature inclusion. This results in considerably longer code reviews. Due to this, AI authored or partially authored PRs may be closed without comment. - [X] I understand these changes in full and will be able to respond to review comments. - [X] My change is accurately described in the commit message. - [X] My contribution is tested and working as described. - [X] I have read the [Developer Certificate of Origin](https://developercertificate.org/) and certify my contribution under its conditions. --------- Co-authored-by: Levi Portenier --- cosmic-settings/src/app.rs | 7 + .../src/pages/input/drawing_tablet.rs | 411 ++++++++++++++++++ cosmic-settings/src/pages/input/mod.rs | 6 +- cosmic-settings/src/pages/mod.rs | 2 + i18n/en/cosmic_settings.ftl | 12 + 5 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 cosmic-settings/src/pages/input/drawing_tablet.rs diff --git a/cosmic-settings/src/app.rs b/cosmic-settings/src/app.rs index e002ec9..b77f643 100644 --- a/cosmic-settings/src/app.rs +++ b/cosmic-settings/src/app.rs @@ -510,6 +510,13 @@ impl cosmic::Application for SettingsApp { } } + #[cfg(feature = "page-input")] + crate::pages::Message::DrawingTablet(message) => { + if let Some(page) = self.pages.page_mut::() { + return page.update(message).map(Into::into); + } + } + #[cfg(feature = "page-input")] crate::pages::Message::Keyboard(message) => { if let Some(page) = self.pages.page_mut::() { diff --git a/cosmic-settings/src/pages/input/drawing_tablet.rs b/cosmic-settings/src/pages/input/drawing_tablet.rs new file mode 100644 index 0000000..0c466ca --- /dev/null +++ b/cosmic-settings/src/pages/input/drawing_tablet.rs @@ -0,0 +1,411 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only +use std::collections::HashMap; + +use cosmic::cosmic_config::{ConfigGet, ConfigSet}; +use cosmic::iced::Subscription; +use cosmic::iced::stream; +use cosmic::widget::{dropdown, settings}; +use cosmic::{Apply, Element, Task}; +use cosmic_comp_config::input::{DeviceState, InputConfig}; +use cosmic_settings_page::{self as page, Section, section}; +use futures::SinkExt; +use slotmap::{Key, SlotMap}; +use udev::Enumerator; + +pub fn default_drawing_tablet_button() -> cosmic::widget::segmented_button::SingleSelectModel { + let mut model = cosmic::widget::segmented_button::SingleSelectModel::builder().build(); + model.activate_position(0); + model +} + +#[derive(Clone, Debug)] +pub enum Message { + SetTabletDeviceState(String, bool), + SetTabletOutput(String, Option), + Surface(cosmic::surface::Action), + RefreshTablets, + RefreshDisplays, +} + +impl From for crate::pages::Message { + fn from(message: Message) -> Self { + crate::pages::Message::DrawingTablet(message) + } +} + +pub struct Page { + entity: page::Entity, + connected_tablets: HashMap>, + connected_displays: Vec, + saved_input_devices: HashMap, + tablet_config: cosmic_config::Config, +} + +pub fn get_connected_tablets() -> HashMap> { + let mut tablets = HashMap::new(); + let Ok(mut device_scanner) = Enumerator::new() else { + return tablets; + }; + + if device_scanner.match_subsystem("input").is_err() { + return tablets; + } + + let Ok(devices) = device_scanner.scan_devices() else { + return tablets; + }; + + for device in devices { + if device + .property_value("ID_INPUT_TABLET") + .is_some_and(|v| v == "1") + { + let physical_device_id = get_physical_device_id(&device); + + let device_name = device.property_value("NAME"); + if !device_name.is_some() { + continue; + } + + tablets.entry(physical_device_id).or_default().push(device); + } + } + + tablets +} + +pub fn get_physical_device_id(device: &udev::Device) -> String { + if let Some(group) = device.property_value("LIBINPUT_DEVICE_GROUP") { + return group.to_string_lossy().into_owned(); + } + + let mut curr = Some(device.clone()); + while let Some(dev) = curr { + if dev.devtype().is_some_and(|t| t == "usb_device") { + return dev.syspath().to_string_lossy().into_owned(); + } + curr = dev.parent(); + } + + device.syspath().to_string_lossy().into_owned() +} + +pub fn get_pen_interface(devices: &Vec) -> Option<&udev::Device> { + devices.into_iter().find(|device| { + let is_tablet = device + .property_value("ID_INPUT_TABLET") + .is_some_and(|v| v == "1"); + + let is_pad = device + .property_value("ID_INPUT_TABLET_PAD") + .is_some_and(|v| v == "1"); + let is_touchpad = device + .property_value("ID_INPUT_TOUCHPAD") + .is_some_and(|v| v == "1"); + + is_tablet && !is_pad && !is_touchpad + }) +} + +pub fn get_connected_displays() -> Vec { + let mut displays: Vec = futures::executor::block_on(async { + cosmic_randr_shell::list() + .await + .map(|l| l.outputs.values().map(|o| o.name.clone()).collect()) + .unwrap_or_default() + }); + + displays.insert(0, fl!("drawing-tablet", "not-set")); + + displays +} + +impl Default for Page { + fn default() -> Self { + let tablet_config = cosmic_config::Config::new("com.system76.CosmicComp", 1).unwrap(); + let saved_input_devices: HashMap = tablet_config + .get("saved_input_devices") + .unwrap_or_else(|_| HashMap::new()); + + let connected_tablets = get_connected_tablets(); + let connected_displays = get_connected_displays(); + + Page { + entity: page::Entity::null(), + connected_tablets, + connected_displays, + saved_input_devices, + tablet_config, + } + } +} + +impl page::Page for Page { + fn set_id(&mut self, entity: page::Entity) { + self.entity = entity; + } + + fn content( + &self, + sections: &mut SlotMap>, + ) -> Option { + let devices_section = Section::default().view::(move |_binder, page, section| { + if page.connected_tablets.is_empty() { + settings::section() + .title(§ion.title) + .add( + settings::item::builder(fl!("drawing-tablet", "no-devices-detected")) + .control(cosmic::widget::space()), + ) + .apply(Element::from) + .map(crate::pages::Message::DrawingTablet) + } else { + let mut sections_column = Vec::new(); + for tablet in page.connected_tablets.values() { + if let Some(pen_interface) = get_pen_interface(tablet) { + if let Some(pen_name) = pen_interface.property_value("NAME") { + let tablet_device_name = + pen_name.to_string_lossy().trim_matches('"').to_owned(); + sections_column.push(tablet_settings_ui(tablet_device_name, page)); + } + } + } + settings::view_column(sections_column).apply(Element::from) + } + }); + + Some(vec![sections.insert(devices_section)]) + } + + fn info(&self) -> page::Info { + page::Info::new("drawing-tablet", "input-tablet-symbolic") + .title(fl!("drawing-tablet")) + .description(fl!("xdg-entry-drawing-tablet-comment")) + } + + fn on_enter(&mut self) -> Task { + Task::none() + } + + fn subscription(&self, _core: &cosmic::Core) -> Subscription { + let tablet_subscription = Subscription::run(|| { + stream::channel( + 1, + move |mut emitter: futures::channel::mpsc::Sender| async move { + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + + let tokio_handle = tokio::runtime::Handle::current(); + std::thread::spawn(move || { + tokio_handle.block_on(async move { + let Ok(builder) = udev::MonitorBuilder::new() else { + return; + }; + + let Ok(builder) = builder.match_subsystem("input") else { + return; + }; + + let Ok(socket) = builder.listen() else { + return; + }; + + let Ok(mut async_fd) = tokio::io::unix::AsyncFd::new(socket) else { + return; + }; + + loop { + if let Ok(mut guard) = async_fd.writable().await { + guard.clear_ready(); + + let input_event_occurred = &mut false; + async_fd.get_mut().iter().for_each(|event| { + let event_type = event.event_type(); + if event_type == udev::EventType::Add + || event_type == udev::EventType::Remove + { + *input_event_occurred = true; + } + }); + + if *input_event_occurred { + if tx.send(()).await.is_err() { + break; + } + } + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + }); + }); + + while let Some(()) = rx.recv().await { + if emitter.send(Message::RefreshTablets.into()).await.is_err() { + break; + } + } + }, + ) + }); + + let display_subscription = Subscription::run(|| { + stream::channel( + 1, + move |mut emitter: futures::channel::mpsc::Sender| async move { + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + + let tokio_handle = tokio::runtime::Handle::current(); + std::thread::spawn(move || { + tokio_handle.block_on(async move { + let Ok(builder) = udev::MonitorBuilder::new() else { + return; + }; + + let Ok(builder) = builder.match_subsystem("drm") else { + return; + }; + + let Ok(socket) = builder.listen() else { + return; + }; + + let Ok(mut async_fd) = tokio::io::unix::AsyncFd::new(socket) else { + return; + }; + + loop { + if let Ok(mut guard) = async_fd.writable().await { + guard.clear_ready(); + + let drm_hotplug_occurred = &mut false; + async_fd.get_mut().iter().for_each(|_| { + *drm_hotplug_occurred = true; + }); + + if *drm_hotplug_occurred { + if tx.send(()).await.is_err() { + break; + } + } + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + }); + }); + + while let Some(()) = rx.recv().await { + if emitter.send(Message::RefreshDisplays.into()).await.is_err() { + break; + } + } + }, + ) + }); + + Subscription::batch(vec![tablet_subscription, display_subscription]) + } +} + +impl page::AutoBind for Page {} + +impl Page { + pub fn update(&mut self, message: Message) -> Task { + match message { + Message::SetTabletDeviceState(device_name, enabled) => { + let state = if enabled { + DeviceState::Enabled + } else { + DeviceState::Disabled + }; + let mut devices: HashMap = self.saved_input_devices.clone(); + devices.entry(device_name).or_default().state = state; + if let Err(err) = self.tablet_config.set("input_devices", &devices) { + tracing::error!(?err, "Failed to set input_devices config"); + } + self.saved_input_devices = devices; + } + + Message::SetTabletOutput(device_name, output) => { + let mut devices: HashMap = self.saved_input_devices.clone(); + devices.entry(device_name).or_default().map_to_output = output; + if let Err(err) = self.tablet_config.set("input_devices", &devices) { + tracing::error!(?err, "Failed to set input_devices config"); + } + self.saved_input_devices = devices; + } + + Message::RefreshTablets => { + self.connected_tablets = get_connected_tablets(); + } + + Message::RefreshDisplays => { + self.connected_displays = get_connected_displays(); + } + + Message::Surface(a) => { + return cosmic::task::message(crate::app::Message::Surface(a)); + } + } + + Task::none() + } +} + +// The returned value represents all the settings UI for a single tablet device. +fn tablet_settings_ui( + tablet_device_name: String, + page: &Page, +) -> Element<'_, crate::pages::Message> { + let mut section_ui = settings::section().title(tablet_device_name.clone()); + let tablet_config = page.saved_input_devices.get(&tablet_device_name); + + let enabled = tablet_config.map_or(false, |c| { + c.state == cosmic_comp_config::input::DeviceState::Enabled + }); + let tablet_name = tablet_device_name.clone(); + let device_state_toggler = settings::item::builder(fl!("drawing-tablet", "enabled")) + .toggler(enabled, { + move |checked| Message::SetTabletDeviceState(tablet_name.clone(), checked) + }); + section_ui = section_ui.add(device_state_toggler); + + if enabled { + let mut display_names = page.connected_displays.clone(); + if let Some(preexisting_output) = tablet_config.and_then(|c| c.map_to_output.as_ref()) { + if !display_names.contains(preexisting_output) { + display_names.push(preexisting_output.clone()); + } + } + + let selected_dropdown_index = tablet_config + .and_then(|c| c.map_to_output.as_ref()) + .and_then(|output_display_name| { + display_names.iter().position(|d| d == output_display_name) + }) + .unwrap_or(0); + let tablet_name = tablet_device_name.clone(); + let dropdown_element = dropdown::popup_dropdown( + display_names.clone(), + Some(selected_dropdown_index), + move |idx| { + let output = if idx == 0 { + None + } else { + Some(display_names[idx].clone()) + }; + Message::SetTabletOutput(tablet_name.clone(), output) + }, + cosmic::iced::window::Id::RESERVED, + Message::Surface, + |a| crate::app::Message::PageMessage(crate::pages::Message::DrawingTablet(a)), + ); + + let dropdown_item = settings::item::builder(fl!("drawing-tablet", "map-to-display")) + .control(dropdown_element); + section_ui = section_ui.add(dropdown_item); + } + + section_ui + .apply(Element::from) + .map(crate::pages::Message::DrawingTablet) +} diff --git a/cosmic-settings/src/pages/input/mod.rs b/cosmic-settings/src/pages/input/mod.rs index 10613dc..d0d8325 100644 --- a/cosmic-settings/src/pages/input/mod.rs +++ b/cosmic-settings/src/pages/input/mod.rs @@ -11,6 +11,7 @@ use cosmic_comp_config::input::{ use cosmic_settings_page as page; use tracing::error; +pub mod drawing_tablet; pub mod keyboard; pub mod mouse; pub mod touchpad; @@ -192,7 +193,10 @@ impl page::Page for Page { impl page::AutoBind for Page { fn sub_pages(page: page::Insert) -> page::Insert { - let insert = page.sub_page::().sub_page::(); + let insert = page + .sub_page::() + .sub_page::() + .sub_page::(); if system_has_touchpad() { insert.sub_page::() diff --git a/cosmic-settings/src/pages/mod.rs b/cosmic-settings/src/pages/mod.rs index 01170b1..e24524e 100644 --- a/cosmic-settings/src/pages/mod.rs +++ b/cosmic-settings/src/pages/mod.rs @@ -64,6 +64,8 @@ pub enum Message { #[cfg(feature = "page-input")] Input(input::Message), #[cfg(feature = "page-input")] + DrawingTablet(input::drawing_tablet::Message), + #[cfg(feature = "page-input")] Keyboard(input::keyboard::Message), #[cfg(feature = "page-input")] KeyboardShortcuts(input::keyboard::shortcuts::Message), diff --git a/i18n/en/cosmic_settings.ftl b/i18n/en/cosmic_settings.ftl index 7318d9a..1a36487 100644 --- a/i18n/en/cosmic_settings.ftl +++ b/i18n/en/cosmic_settings.ftl @@ -61,6 +61,10 @@ xdg-entry-mouse = Mouse xdg-entry-mouse-comment = Mouse speed, acceleration, and natural scrolling xdg-entry-mouse-keywords = COSMIC;Mouse;Acceleration;Scrolling; +xdg-entry-drawing-tablet = Drawing tablet +xdg-entry-drawing-tablet-comment = Display mapping +xdg-entry-drawing-tablet-keywords = COSMIC;Tablet;Drawing;Sensitivity; + xdg-entry-network = Network & Wireless xdg-entry-network-comment = Manage network connections xdg-entry-network-keywords = COSMIC;Network;Wireless;WiFi;VPN; @@ -948,6 +952,14 @@ switch-between-windows = Switch between windows open-application-library = Open Application Library open-workspaces-view = Open Workspaces Overview +## Input: Tablet + +drawing-tablet = Drawing tablet + .no-devices-detected = No drawing tablets or pen devices detected + .map-to-display = Map to display + .not-set = Not Set + .enabled = Enabled + ## Time & language time = Time & language