diff --git a/Cargo.lock b/Cargo.lock index 1655f24..ce96342 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -253,7 +253,7 @@ checksum = "0e97ce7de6cf12de5d7226c73f5ba9811622f4db3a5b91b55c53e987e5f91cba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.28", ] [[package]] @@ -270,7 +270,7 @@ checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.28", ] [[package]] @@ -445,7 +445,7 @@ checksum = "fdde5c9cd29ebd706ce1b35600920a33550e402fc998a2e53ad3b42c3c47a192" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.28", ] [[package]] @@ -732,6 +732,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "cosmic-comp-config" +version = "0.1.0" +source = "git+https://github.com/pop-os/cosmic-comp#1392fc7c953678a14825ba3d1e5619d38c1946c7" +dependencies = [ + "input", + "serde", +] + [[package]] name = "cosmic-config" version = "0.1.0" @@ -778,11 +787,13 @@ dependencies = [ "apply", "async-channel", "color-eyre", + "cosmic-comp-config", "cosmic-panel-config", "cosmic-settings-desktop", "cosmic-settings-page", "cosmic-settings-system", "cosmic-settings-time", + "derivative", "derive_setters", "dirs 5.0.1", "downcast-rs", @@ -792,12 +803,14 @@ dependencies = [ "i18n-embed", "i18n-embed-fl", "image", + "itertools 0.11.0", "libcosmic", "log", "notify", "once_cell", "regex", "rust-embed", + "serde", "slotmap", "tokio", "tracing", @@ -1069,7 +1082,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.22", + "syn 2.0.28", ] [[package]] @@ -1091,7 +1104,7 @@ checksum = "29a358ff9f12ec09c3e61fef9b5a9902623a695a46a917b07f269bff1445611a" dependencies = [ "darling_core 0.20.1", "quote", - "syn 2.0.22", + "syn 2.0.28", ] [[package]] @@ -1133,7 +1146,7 @@ dependencies = [ "darling 0.20.1", "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.28", ] [[package]] @@ -1219,7 +1232,7 @@ checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.28", ] [[package]] @@ -1292,7 +1305,7 @@ checksum = "5e9a1f9f7d83e59740248a6e14ecf93929ade55027844dfcea78beafccc15745" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.28", ] [[package]] @@ -1576,7 +1589,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.28", ] [[package]] @@ -1739,7 +1752,7 @@ checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.28", ] [[package]] @@ -2083,7 +2096,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.22", + "syn 2.0.28", "unic-langid", ] @@ -2206,7 +2219,7 @@ dependencies = [ "iced_graphics", "iced_runtime", "iced_style", - "itertools", + "itertools 0.10.5", "raw-window-handle 0.5.2", "smithay-client-toolkit 0.17.0", "smithay-clipboard", @@ -2441,6 +2454,29 @@ dependencies = [ "libc", ] +[[package]] +name = "input" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e74cd82cedcd66db78742a8337bdc48f188c4d2c12742cbc5cd85113f0b059" +dependencies = [ + "bitflags 1.3.2", + "input-sys", + "io-lifetimes", + "libc", + "log", + "udev", +] + +[[package]] +name = "input-sys" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f6c2a17e8aba7217660e32863af87b0febad811d4b8620ef76b386603fddc2" +dependencies = [ + "libc", +] + [[package]] name = "instant" version = "0.1.12" @@ -2504,6 +2540,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "jni-sys" version = "0.3.0" @@ -2649,6 +2694,16 @@ version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7012b1bbb0719e1097c47611d3898568c546d597c2e74d66f6087edd5233ff4" +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "linux-raw-sys" version = "0.3.8" @@ -3260,7 +3315,7 @@ checksum = "3c02bfa6b3ba8af5434fa0531bf5701f750d983d4260acd6867faca51cdc4484" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.28", ] [[package]] @@ -3353,7 +3408,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.28", ] [[package]] @@ -3388,7 +3443,7 @@ checksum = "39407670928234ebc5e6e580247dd567ad73a3578460c5990f9503df207e8f07" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.28", ] [[package]] @@ -3737,7 +3792,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.22", + "syn 2.0.28", "walkdir", ] @@ -3860,22 +3915,22 @@ checksum = "1ef965a420fe14fdac7dd018862966a4c14094f900e1650bbc71ddd7d580c8af" [[package]] name = "serde" -version = "1.0.164" +version = "1.0.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e8c8cf938e98f769bc164923b06dce91cea1751522f46f8466461af04c9027d" +checksum = "0ea67f183f058fe88a4e3ec6e2788e003840893b91bac4559cabedd00863b3ed" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.164" +version = "1.0.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9735b638ccc51c28bf6914d90a2e9725b377144fc612c49a611fddd1b631d68" +checksum = "24e744d7782b686ab3b73267ef05697159cc0e5abbed3f47f9933165e5219036" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.28", ] [[package]] @@ -3886,7 +3941,7 @@ checksum = "bcec881020c684085e55a25f7fd888954d56609ef363479dc5a1305eb0d40cab" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.28", ] [[package]] @@ -4205,9 +4260,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.22" +version = "2.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efbeae7acf4eabd6bcdcbd11c92f45231ddda7539edc7806bd1a04a03b24616" +checksum = "04361975b3f5e348b2189d8dc55bc942f278b2d482a6a0365de5bdd62d351567" dependencies = [ "proc-macro2", "quote", @@ -4297,7 +4352,7 @@ checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.28", ] [[package]] @@ -4487,7 +4542,7 @@ checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.28", ] [[package]] @@ -4567,6 +4622,17 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" +[[package]] +name = "udev" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebdbbd670373442a12fe9ef7aeb53aec4147a5a27a00bbc3ab639f08f48191a" +dependencies = [ + "libc", + "libudev-sys", + "pkg-config", +] + [[package]] name = "uds_windows" version = "1.0.2" @@ -4813,7 +4879,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.28", "wasm-bindgen-shared", ] @@ -4847,7 +4913,7 @@ checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.28", "wasm-bindgen-backend", "wasm-bindgen-shared", ] diff --git a/Cargo.toml b/Cargo.toml index 71460e8..6199186 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,10 @@ git = "https://github.com/pop-os/libcosmic" [workspace.dependencies.cosmic-bg-config] git = "https://github.com/pop-os/cosmic-bg" +[workspace.dependencies.cosmic-comp-config] +git = "https://github.com/pop-os/cosmic-comp" +# path = "../cosmic-comp/cosmic-comp-config" + [workspace.dependencies.cosmic-panel-config] git = "https://github.com/pop-os/cosmic-panel" diff --git a/app/Cargo.toml b/app/Cargo.toml index 0bc7e75..87ffe88 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -13,10 +13,12 @@ cosmic-settings-desktop = { path = "../pages/desktop" } cosmic-settings-page = { path = "../page" } cosmic-settings-system = { path = "../pages/system" } cosmic-settings-time = { path = "../pages/time" } +derivative = "2.2.0" derive_setters = "0.1.6" dirs = "5.0.1" generator = "0.7.4" i18n-embed-fl = "0.6.7" +itertools = "0.11.0" libcosmic = {workspace = true} once_cell = "1.17.2" regex = "1.8.3" @@ -24,6 +26,7 @@ rust-embed = "6.6.1" slotmap = "1.0.6" tokio = "1.28.2" downcast-rs = "1.2.0" +cosmic-comp-config = { workspace = true } # TODO: migrate this dependency to the pages/desktop crate. cosmic-panel-config = { workspace = true } tracing = "0.1.37" @@ -35,6 +38,7 @@ freedesktop-desktop-entry = "0.5.0" notify = "6.0.0" anyhow = "1.0" image = "0.24.6" +serde = { version = "1.0.180", features = ["derive"] } [dependencies.i18n-embed] version = "0.13.9" diff --git a/app/src/app.rs b/app/src/app.rs index 73c3301..0fa900d 100644 --- a/app/src/app.rs +++ b/app/src/app.rs @@ -39,6 +39,7 @@ use crate::{ applets::{self, APPLET_DND_ICON_ID}, }, }, + input::{self, keyboard}, sound, system, time, }, subscription::desktop_files, @@ -142,6 +143,8 @@ impl Application for SettingsApp { // app.insert_page::(); // app.insert_page::(); + // + app.insert_page::(); let active_id = app .pages @@ -268,6 +271,11 @@ impl Application for SettingsApp { crate::pages::Message::DesktopWallpaper(message) => { page::update!(self.pages, message, desktop::wallpaper::Page); } + crate::pages::Message::Input(message) => { + if let Some(page) = self.pages.page_mut::() { + return page.update(message); + } + } crate::pages::Message::External { .. } => { todo!("external plugins not supported yet"); } @@ -326,6 +334,16 @@ impl Application for SettingsApp { { return page.add_applet_view(); } + if let Some(Some(page)) = + (id == keyboard::ADD_INPUT_SOURCE_DIALOGUE_ID).then(|| self.pages.page::()) + { + return page.add_input_source_view(); + } + if let Some(Some(page)) = (id == keyboard::SPECIAL_CHARACTER_DIALOGUE_ID) + .then(|| self.pages.page::()) + { + return page.special_character_key_view(); + } cosmic::iced::widget::responsive(|size| { let is_condensed = (600.0 * self.scaling_factor) > size.width; diff --git a/app/src/pages/input/keyboard/mod.rs b/app/src/pages/input/keyboard/mod.rs new file mode 100644 index 0000000..6ffd5bb --- /dev/null +++ b/app/src/pages/input/keyboard/mod.rs @@ -0,0 +1,331 @@ +use apply::Apply; +use cosmic::{ + iced::{ + self, + widget::{self, horizontal_space}, + window, Length, + }, + iced_style, theme, + widget::settings, +}; +use cosmic_settings_page::{self as page, section, Section}; +use slotmap::SlotMap; + +use super::Message; + +pub const ADD_INPUT_SOURCE_DIALOGUE_ID: window::Id = window::Id(2000); +pub const SPECIAL_CHARACTER_DIALOGUE_ID: window::Id = window::Id(2001); + +static COMPOSE_OPTIONS: &[(&str, &str)] = &[ + // ("Left Alt", "compose:lalt"), XXX? + ("Right Alt", "compose:ralt"), + ("Left Super", "compose:lwin"), + ("Right Super", "compose:rwin"), + ("Menu key", "compose:menu"), + ("Right Ctrl", "compose:rctrl"), + ("Caps Lock", "compose:caps"), + ("Scroll Lock", "compose:sclk"), + ("Print Screen", "compose:prsc"), +]; + +static ALTERNATE_CHARACTER_OPTIONS: &[(&str, &str)] = &[ + ("Left Alt", "lv3:lalt_switch"), + ("Right Alt", "lv3:alt_switch"), + ("Left Super", "lv3:lwin_switch"), + ("Right Super", "lv3:win_switch"), + ("Menu key", "lv3:menu_switch"), + // ("Right Ctrl", "lv3:"), XXX + ("Caps Lock", "lv3:caps_switch"), + // ("Scroll Lock", "lv3:"), XXX + // ("Print Screen", "lv3"), XXX +]; + +#[derive(Copy, Clone, Debug)] +pub enum SpecialKey { + AlternateCharacters, + Compose, +} + +impl SpecialKey { + pub fn title(self) -> String { + match self { + Self::Compose => "Compose".to_string(), + Self::AlternateCharacters => "Alternate Characters".to_string(), + } + } + + pub fn prefix(self) -> &'static str { + match self { + Self::Compose => "compose:", + Self::AlternateCharacters => "lv3:", + } + } +} + +fn popover_menu_row(label: String) -> cosmic::Element<'static, Message> { + widget::text(label) + .apply(widget::container) + .style(cosmic::theme::Container::custom(|theme| { + iced_style::container::Appearance { + background: None, + ..cosmic::widget::list::column::style(theme) + } + })) + .apply(widget::button) + .style(cosmic::theme::Button::Transparent) + .into() +} + +// TODO for on press, would need to clone ID for each row? +fn popover_menu() -> cosmic::Element<'static, Message> { + // XXX translate + widget::column![ + popover_menu_row(fl!("keyboard-sources", "move-up")), + popover_menu_row(fl!("keyboard-sources", "move-down")), + //cosmic::widget::divider::horizontal::light(), + cosmic::widget::divider::horizontal::light(), + popover_menu_row(fl!("keyboard-sources", "settings")), + popover_menu_row(fl!("keyboard-sources", "view-layout")), + popover_menu_row(fl!("keyboard-sources", "remove")), + ] + .width(Length::Shrink) + .height(Length::Shrink) + .apply(cosmic::widget::container) + .style(cosmic::theme::Container::custom(|theme| { + iced_style::container::Appearance { + text_color: Some(theme.cosmic().background.on.into()), + background: Some(iced::Color::from(theme.cosmic().background.base).into()), + border_radius: (12.0).into(), + border_width: 0.0, + border_color: iced::Color::TRANSPARENT, + } + })) + .into() +} + +fn popover_button(input_source: &InputSource, expanded: bool) -> cosmic::Element<'static, Message> { + let style = if expanded { + cosmic::theme::Svg::SymbolicActive + } else { + cosmic::theme::Svg::Symbolic + }; + let on_press = Message::ExpandInputSourcePopover(if expanded { + None + } else { + Some(input_source.id.clone()) + }); + let button = cosmic::widget::button(cosmic::theme::Button::Secondary) + .icon(style, "open-menu-symbolic", 20) + .padding(0) + .on_press(on_press); + + if expanded { + cosmic::widget::popover(button, popover_menu()).into() + } else { + button.into() + } +} + +fn input_source<'a>( + input_source: &'a InputSource, + expanded_source_popover: Option<&'a str>, +) -> cosmic::Element<'a, Message> { + let expanded = expanded_source_popover == Some(input_source.id.as_str()); + settings::item(&input_source.label, popover_button(input_source, expanded)).into() +} + +pub mod shortcuts; + +pub struct InputSource { + id: String, + // TODO Translate? + label: String, +} + +impl super::Page { + pub fn add_input_source_view(&self) -> cosmic::Element<'static, crate::app::Message> { + widget::column![].into() + } + + pub fn special_character_key_view(&self) -> cosmic::Element<'_, crate::app::Message> { + let Some(special_key) = self.special_character_dialog else { + return widget::text("").into(); + }; + + let options = match special_key { + SpecialKey::Compose => COMPOSE_OPTIONS, + SpecialKey::AlternateCharacters => ALTERNATE_CHARACTER_OPTIONS, + }; + let prefix = special_key.prefix(); + let current = self + .xkb + .options + .iter() + .flat_map(|x| x.split(',')) + .find(|x| x.starts_with(prefix)); + + // TODO description, layout default + + let mut list = cosmic::widget::list_column(); + list = list.add(special_char_radio_row("None", None, current)); + for (desc, id) in options { + list = list.add(special_char_radio_row(desc, Some(id), current)); + } + widget::column![ + cosmic::widget::header_bar() + .title(special_key.title()) + .on_close(Message::CloseSpecialCharacterDialog), + cosmic::widget::container( + cosmic::widget::scrollable(cosmic::widget::container(list).padding(24)) + .width(Length::Fill) + .height(Length::Fill) + ) + .style(theme::Container::Background) + .width(Length::Fill) + .height(Length::Fill) + ] + .apply(cosmic::Element::from) + .map(crate::pages::Message::Input) + .map(crate::app::Message::PageMessage) + } +} + +fn special_char_radio_row<'a>( + desc: &'a str, + value: Option<&'static str>, + current_value: Option<&'a str>, +) -> cosmic::Element<'a, Message> { + settings::item_row(vec![iced::widget::radio( + desc, + value, + Some(current_value), + |_| Message::SpecialCharacterSelect(value), + ) + .into()]) + .into() +} + +#[derive(Default)] +pub struct Page; + +// XXX +pub fn default_input_sources() -> Vec { + vec![InputSource { + id: "us".to_string(), + label: "English (US)".to_string(), + }] +} + +impl page::Page for Page { + fn content( + &self, + sections: &mut SlotMap>, + ) -> Option { + Some(vec![ + sections.insert(input_sources()), + sections.insert(special_character_entry()), + sections.insert(keyboard_shortcuts()), + ]) + } + + fn info(&self) -> page::Info { + page::Info::new("keyboard", "input-keyboard-symbolic") + .title(fl!("keyboard")) + .description(fl!("keyboard", "desc")) + } +} + +impl page::AutoBind for Page { + fn sub_pages(page: page::Insert) -> page::Insert { + page.sub_page::() + } +} + +fn input_sources() -> Section { + // TODO desc + Section::default() + .title(fl!("keyboard-sources")) + .view::(|binder, _page, section| { + let input = binder.page::().expect("input page not found"); + + // TODO Need something more custom, with drag and drop + let mut section = settings::view_section(§ion.title); + + let expanded_source = input.expanded_source_popover.as_deref(); + for source in &input.sources { + section = section.add(input_source(source, expanded_source)); + } + + section + .apply(cosmic::Element::from) + .map(crate::pages::Message::Input) + }) +} + +fn special_character_entry() -> Section { + Section::default() + .title(fl!("keyboard-special-char")) + .descriptions(vec![ + fl!("keyboard-special-char", "alternate"), + fl!("keyboard-special-char", "compose"), + ]) + .view::(|_binder, _page, section| { + let descriptions = §ion.descriptions; + + // TODO dialogs + settings::view_section(§ion.title) + .add(go_next_item( + &descriptions[0], + Message::OpenSpecialCharacterDialog(SpecialKey::AlternateCharacters), + )) + .add(go_next_item( + &descriptions[1], + Message::OpenSpecialCharacterDialog(SpecialKey::Compose), + )) + .apply(cosmic::Element::from) + .map(crate::pages::Message::Input) + }) +} + +fn keyboard_shortcuts() -> Section { + Section::default() + .title(fl!("keyboard-shortcuts")) + .descriptions(vec![fl!("keyboard-shortcuts", "desc")]) + .view::(|binder, _page, section| { + let descriptions = §ion.descriptions; + + let mut section = settings::view_section(§ion.title); + if let Some((shortcuts_entity, _)) = binder + .info + .iter() + .find(|(_, v)| v.id == "keyboard-shortcuts") + { + section = section.add(go_next_item( + &descriptions[0], + crate::pages::Message::Page(shortcuts_entity), + )); + } + section.apply(cosmic::Element::from) + }) +} + +fn go_next_control() -> cosmic::Element<'static, Msg> { + widget::row!( + horizontal_space(Length::Fill), + cosmic::widget::icon("go-next-symbolic", 20).style(cosmic::theme::Svg::Symbolic) + ) + .into() +} + +fn go_next_item(description: &str, msg: Msg) -> cosmic::Element<'_, Msg> { + settings::item(description, go_next_control()) + .apply(widget::container) + .style(cosmic::theme::Container::custom( + cosmic::widget::list::column::style, + )) + .apply(widget::button) + .style(cosmic::theme::Button::Transparent) + .padding(0) + .on_press(msg) + .into() +} diff --git a/app/src/pages/input/keyboard/shortcuts.rs b/app/src/pages/input/keyboard/shortcuts.rs new file mode 100644 index 0000000..4b0e036 --- /dev/null +++ b/app/src/pages/input/keyboard/shortcuts.rs @@ -0,0 +1,51 @@ +use apply::Apply; +use cosmic::iced::widget; +use cosmic::widget::settings; +use cosmic::Element; +use cosmic_settings_page::Section; +use cosmic_settings_page::{self as page, section}; +use slotmap::SlotMap; + +#[derive(Default)] +pub struct Page; + +//crate::app::Message::Page + +impl page::Page for Page { + fn content( + &self, + sections: &mut SlotMap>, + ) -> Option { + Some(vec![sections.insert(shortcuts())]) + } + + fn info(&self) -> page::Info { + page::Info::new("keyboard-shortcuts", "input-keyboard-symbolic") + .title(fl!("keyboard-shortcuts")) + .description(fl!("keyboard-shortcuts", "desc")) + } +} + +impl page::AutoBind for Page {} + +fn shortcuts() -> Section { + Section::default() + .descriptions(vec![]) + .view::(|binder, _page, section| { + let _descriptions = §ion.descriptions; + + let _input = binder + .page::() + .expect("input page not found"); + + // TODO need something more custom + /* + settings::view_section(§ion.title) + .apply(Element::from) + .map(crate::pages::Message::Input) + */ + widget::column![settings::view_section(§ion.title)] + .apply(Element::from) + .map(crate::pages::Message::Input) + }) +} diff --git a/app/src/pages/input/mod.rs b/app/src/pages/input/mod.rs new file mode 100644 index 0000000..f526178 --- /dev/null +++ b/app/src/pages/input/mod.rs @@ -0,0 +1,221 @@ +use crate::app; +use cosmic::{ + cosmic_config::{self, ConfigGet, ConfigSet}, + iced::{self, wayland::actions::window::SctkWindowSettings, window}, + iced_sctk::commands, + iced_widget::core::layout, +}; +use cosmic_comp_config::{ + input::{AccelProfile, InputConfig}, + XkbConfig, +}; +use cosmic_settings_page as page; +use itertools::Itertools; +use tracing::error; + +pub mod keyboard; +mod mouse; +mod touchpad; + +#[derive(Clone, Debug)] +pub enum Message { + SetAcceleration(bool, bool), + SetNaturalScroll(bool, bool), + SetScrollFactor(f64, bool), + SetDoubleClickSpeed(u32, bool), + SetMouseSpeed(f64, bool), + PrimaryButtonSelected(cosmic::widget::segmented_button::Entity, bool), + // seperate close message, to make sure another isn't closed? + ExpandInputSourcePopover(Option), + OpenSpecialCharacterDialog(keyboard::SpecialKey), + CloseSpecialCharacterDialog, + SpecialCharacterSelect(Option<&'static str>), +} + +pub struct Page { + config: cosmic_config::Config, + input_default: InputConfig, + #[allow(dead_code)] + input_touchpad: InputConfig, + + // Mouse + primary_button: cosmic::widget::segmented_button::SingleSelectModel, + + // Touchpad + touchpad_primary_button: cosmic::widget::segmented_button::SingleSelectModel, + + // Keyboard + expanded_source_popover: Option, + sources: Vec, + special_character_dialog: Option, + xkb: XkbConfig, +} + +fn get_config( + config: &cosmic_config::Config, + key: &str, +) -> T { + config.get(key).unwrap_or_else(|err| { + error!(?err, "Failed to read config '{}'", key); + T::default() + }) +} + +impl Default for Page { + fn default() -> Self { + let config = cosmic_config::Config::new("com.system76.CosmicComp", 1).unwrap(); + let input_default: InputConfig = get_config(&config, "input-default"); + let input_touchpad: InputConfig = get_config(&config, "input-touchpad"); + let xkb = get_config(&config, "xkb-config"); + + let mut primary_button = mouse::default_primary_button(); + let idx = if input_default.left_handed.unwrap_or(false) { + 1 + } else { + 0 + }; + primary_button.activate_position(idx); + + let mut touchpad_primary_button = mouse::default_primary_button(); + let idx = if input_touchpad.left_handed.unwrap_or(false) { + 1 + } else { + 0 + }; + touchpad_primary_button.activate_position(idx); + + Self { + config, + input_default, + input_touchpad, + + // Mouse + primary_button, + + // Touchpad + touchpad_primary_button, + + // Keyboard + expanded_source_popover: None, + sources: keyboard::default_input_sources(), + special_character_dialog: None, + xkb, + } + } +} + +impl Page { + fn update_input(&mut self, touchpad: bool, f: F) { + let (name, input_config) = if touchpad { + ("input-touchpad", &mut self.input_touchpad) + } else { + ("input-default", &mut self.input_default) + }; + f(input_config); + if let Err(err) = self.config.set(name, input_config) { + error!(?err, "Failed to set config '{}'", name); + } + } + + pub fn update(&mut self, message: Message) -> iced::Command { + match message { + Message::SetAcceleration(value, touchpad) => { + let profile = if value { + AccelProfile::Adaptive + } else { + AccelProfile::Flat + }; + self.update_input(touchpad, |x| { + x.acceleration.get_or_insert(Default::default()).profile = Some(profile); + }); + } + Message::SetNaturalScroll(value, touchpad) => self.update_input(touchpad, |x| { + x.scroll_config + .get_or_insert(Default::default()) + .natural_scroll = Some(value); + }), + Message::SetScrollFactor(value, touchpad) => self.update_input(touchpad, |x| { + x.scroll_config + .get_or_insert(Default::default()) + .scroll_factor = Some(value) + }), + Message::SetDoubleClickSpeed(_value, _touchpad) => { + // TODO + } + Message::SetMouseSpeed(value, touchpad) => self.update_input(touchpad, |x| { + x.acceleration.get_or_insert(Default::default()).speed = value + }), + Message::PrimaryButtonSelected(entity, touchpad) => { + let select_model = if touchpad { + &mut self.touchpad_primary_button + } else { + &mut self.primary_button + }; + select_model.activate(entity); + let left_entity = select_model.entity_at(1).unwrap(); + let left_handed = select_model.active() == left_entity; + self.update_input(touchpad, |x| x.left_handed = Some(left_handed)); + } + Message::ExpandInputSourcePopover(value) => { + self.expanded_source_popover = value; + } + Message::OpenSpecialCharacterDialog(special_key) => { + self.special_character_dialog = Some(special_key); + let window_settings = SctkWindowSettings { + window_id: keyboard::SPECIAL_CHARACTER_DIALOGUE_ID, + app_id: Some("com.system76.CosmicSettings".to_string()), + title: Some(special_key.title()), + parent: Some(window::Id(0)), + autosize: false, + size_limits: layout::Limits::NONE + .min_width(300.0) + .max_width(800.0) + .min_height(200.0) + .max_height(1080.0), + size: (512, 420), + resizable: None, + client_decorations: true, + transparent: true, + }; + return commands::window::get_window(window_settings); + } + Message::CloseSpecialCharacterDialog => { + self.special_character_dialog = None; + return commands::window::close_window(keyboard::SPECIAL_CHARACTER_DIALOGUE_ID); + } + Message::SpecialCharacterSelect(id) => { + if let Some(special_key) = self.special_character_dialog { + let options = self.xkb.options.as_deref().unwrap_or(""); + let prefix = special_key.prefix(); + let new_options = options + .split(',') + .filter(|x| !x.starts_with(prefix)) + .chain(id.into_iter()) + .join(","); + self.xkb.options = Some(new_options).filter(|x| !x.is_empty()); + if let Err(err) = self.config.set("xkb-config", &self.xkb) { + error!(?err, "Failed to set config 'xkb-config'"); + } + } + } + } + iced::Command::none() + } +} + +impl page::Page for Page { + fn info(&self) -> page::Info { + // XXX icon? + page::Info::new("input", "input-keyboard-symbolic") + .title(fl!("input")) + .description(fl!("input", "desc")) + } +} + +impl page::AutoBind for Page { + fn sub_pages(page: page::Insert) -> page::Insert { + page.sub_page::() + .sub_page::() + .sub_page::() + } +} diff --git a/app/src/pages/input/mouse.rs b/app/src/pages/input/mouse.rs new file mode 100644 index 0000000..2c88456 --- /dev/null +++ b/app/src/pages/input/mouse.rs @@ -0,0 +1,147 @@ +use apply::Apply; +use cosmic::iced::widget; +use cosmic::widget::settings; +use cosmic::Element; +use cosmic_comp_config::input::AccelProfile; +use cosmic_settings_page::Section; +use cosmic_settings_page::{self as page, section}; +use slotmap::SlotMap; + +use super::Message; + +pub fn default_primary_button() -> cosmic::widget::segmented_button::SingleSelectModel { + let mut model = cosmic::widget::segmented_button::SingleSelectModel::builder() + .insert(|b| b.text(fl!("mouse", "primary-button-left"))) + .insert(|b| b.text(fl!("mouse", "primary-button-right"))) + .build(); + model.activate_position(0); + model +} + +#[derive(Default)] +pub struct Page; + +impl page::Page for Page { + fn content( + &self, + sections: &mut SlotMap>, + ) -> Option { + Some(vec![sections.insert(mouse()), sections.insert(scrolling())]) + } + + fn info(&self) -> page::Info { + page::Info::new("mouse", "input-mouse-symbolic") + .title(fl!("mouse")) + .description(fl!("mouse", "desc")) + } +} + +impl page::AutoBind for Page {} + +fn mouse() -> Section { + Section::default() + .descriptions(vec![ + fl!("mouse", "primary-button"), + fl!("mouse", "speed"), + fl!("mouse", "acceleration"), + fl!("mouse", "acceleration-desc"), + fl!("mouse", "double-click-speed"), + fl!("mouse", "double-click-speed-desc"), + ]) + .view::(|binder, _page, section| { + let descriptions = §ion.descriptions; + + let input = binder.page::().expect("input page not found"); + + settings::view_section(§ion.title) + .add(settings::item( + &descriptions[0], + cosmic::widget::segmented_selection::horizontal(&input.primary_button) + .on_activate(|x| Message::PrimaryButtonSelected(x, false)), + )) + .add( + settings::item::builder(&descriptions[1]).control(widget::slider( + 0.0..=100.0, + (input + .input_default + .acceleration + .as_ref() + .map_or(0.0, |x| x.speed) + + 1.0) + * 50.0, + |value| Message::SetMouseSpeed((value / 50.0) - 1.0, false), + )), + ) + .add( + settings::item::builder(&descriptions[2]) + .description(&descriptions[3]) + .toggler( + input + .input_default + .acceleration + .as_ref() + .map_or(true, |x| x.profile == Some(AccelProfile::Adaptive)), + |x| Message::SetAcceleration(x, false), + ), + ) + .add( + settings::item::builder(&descriptions[4]) + .description(&descriptions[5]) + .control(widget::slider(0..=100, 0, |x| { + Message::SetDoubleClickSpeed(x, false) + })), + ) + .apply(Element::from) + .map(crate::pages::Message::Input) + }) +} + +fn scrolling() -> Section { + Section::default() + .title(fl!("mouse-scrolling")) + .descriptions(vec![ + fl!("mouse-scrolling", "speed"), + fl!("mouse-scrolling", "natural"), + fl!("mouse-scrolling", "natural-desc"), + ]) + .view::(|binder, _page, section| { + let descriptions = §ion.descriptions; + + let input = binder.page::().expect("input page not found"); + + settings::view_section(§ion.title) + .add(settings::item( + &descriptions[0], + // TODO show numeric value + // TODO desired range? + widget::slider( + 1.0..=100.0, + input + .input_default + .scroll_config + .as_ref() + .and_then(|x| x.scroll_factor) + .unwrap_or(1.) + .log(2.) + * 10.0 + + 50.0, + |value| Message::SetScrollFactor(2f64.powf((value - 50.0) / 10.0), false), + ), + )) + .add( + settings::item::builder(&descriptions[1]) + .description(&descriptions[2]) + .toggler( + input + .input_default + .scroll_config + .as_ref() + .and_then(|x| x.natural_scroll) + .unwrap_or(false), + |x| Message::SetNaturalScroll(x, false), + ), + ) + .apply(Element::from) + .map(crate::pages::Message::Input) + }) +} diff --git a/app/src/pages/input/touchpad.rs b/app/src/pages/input/touchpad.rs new file mode 100644 index 0000000..6e2305e --- /dev/null +++ b/app/src/pages/input/touchpad.rs @@ -0,0 +1,142 @@ +use apply::Apply; +use cosmic::iced::widget; +use cosmic::widget::settings; +use cosmic::Element; +use cosmic_comp_config::input::AccelProfile; +use cosmic_settings_page::Section; +use cosmic_settings_page::{self as page, section}; +use slotmap::SlotMap; + +use super::Message; + +#[derive(Default)] +pub struct Page; + +impl page::Page for Page { + fn content( + &self, + sections: &mut SlotMap>, + ) -> Option { + Some(vec![ + sections.insert(touchpad()), + sections.insert(scrolling()), + ]) + } + + fn info(&self) -> page::Info { + page::Info::new("touchpad", "input-touchpad-symbolic") + .title(fl!("touchpad")) + .description(fl!("touchpad", "desc")) + } +} + +impl page::AutoBind for Page {} + +fn touchpad() -> Section { + Section::default() + .descriptions(vec![ + fl!("touchpad", "primary-button"), + fl!("touchpad", "speed"), + fl!("touchpad", "acceleration"), + fl!("touchpad", "acceleration-desc"), + fl!("touchpad", "double-click-speed"), + fl!("touchpad", "double-click-speed-desc"), + ]) + .view::(|binder, _page, section| { + let descriptions = §ion.descriptions; + + let input = binder.page::().expect("input page not found"); + + settings::view_section(§ion.title) + .add(settings::item( + &descriptions[0], + cosmic::widget::segmented_selection::horizontal(&input.touchpad_primary_button) + .on_activate(|x| Message::PrimaryButtonSelected(x, true)), + )) + .add( + settings::item::builder(&descriptions[1]).control(widget::slider( + 0.0..=100.0, + (input + .input_touchpad + .acceleration + .as_ref() + .map_or(0.0, |x| x.speed) + + 1.0) + * 50.0, + |value| Message::SetMouseSpeed((value / 50.0) - 1.0, true), + )), + ) + .add( + settings::item::builder(&descriptions[2]) + .description(&descriptions[3]) + .toggler( + input + .input_touchpad + .acceleration + .as_ref() + .map_or(true, |x| x.profile == Some(AccelProfile::Adaptive)), + |x| Message::SetAcceleration(x, true), + ), + ) + // TODO disable while typing + .add( + settings::item::builder(&descriptions[4]) + .description(&descriptions[5]) + .control(widget::slider(0..=100, 0, |x| { + Message::SetDoubleClickSpeed(x, true) + })), + ) + .apply(Element::from) + .map(crate::pages::Message::Input) + }) +} + +fn scrolling() -> Section { + Section::default() + .title(fl!("mouse-scrolling")) + .descriptions(vec![ + fl!("mouse-scrolling", "speed"), + fl!("mouse-scrolling", "natural"), + fl!("mouse-scrolling", "natural-desc"), + ]) + .view::(|binder, _page, section| { + let descriptions = §ion.descriptions; + + let input = binder.page::().expect("input page not found"); + + settings::view_section(§ion.title) + .add(settings::item( + &descriptions[0], + // TODO show numeric value + // TODO desired range? + widget::slider( + 1.0..=100.0, + input + .input_touchpad + .scroll_config + .as_ref() + .and_then(|x| x.scroll_factor) + .unwrap_or(1.) + .log(2.) + * 10.0 + + 50.0, + |value| Message::SetScrollFactor(2f64.powf((value - 50.0) / 10.0), true), + ), + )) + .add( + settings::item::builder(&descriptions[1]) + .description(&descriptions[2]) + .toggler( + input + .input_touchpad + .scroll_config + .as_ref() + .and_then(|x| x.natural_scroll) + .unwrap_or(false), + |x| Message::SetNaturalScroll(x, true), + ), + ) + .apply(Element::from) + .map(crate::pages::Message::Input) + }) +} diff --git a/app/src/pages/mod.rs b/app/src/pages/mod.rs index 019986e..c265e0f 100644 --- a/app/src/pages/mod.rs +++ b/app/src/pages/mod.rs @@ -4,6 +4,7 @@ use cosmic_settings_page::Entity; pub mod desktop; +pub mod input; pub mod networking; pub mod sound; pub mod system; @@ -17,6 +18,7 @@ pub enum Message { Panel(desktop::panel::Message), DesktopWallpaper(desktop::wallpaper::Message), Applet(desktop::panel::applets::Message), + Input(input::Message), External { id: String, message: Vec }, Page(Entity), } diff --git a/i18n/en/cosmic_settings.ftl b/i18n/en/cosmic_settings.ftl index 99f5eef..f1f0822 100644 --- a/i18n/en/cosmic_settings.ftl +++ b/i18n/en/cosmic_settings.ftl @@ -221,3 +221,60 @@ firmware = Firmware users = Users .desc = Authentication and login, lock screen. + +## Input + +input = Input + .desc = Input + +## Input: Keyboard + +keyboard = Keyboard + .desc = Keyboard input + +keyboard-sources = Input Sources + .desc = Input sources can be switched using Super+Space key combination. This can be customized in the keyboard shortcut settings. + .move-up = Move up + .move-down = Move down + .settings = Settings + .view-layout = View keyboard layout + .remove = Remove + +keyboard-special-char = Special Character Entry + .alternate = Alternate characters key + .compose = Compose key + +## Input: Keyboard: Shortcuts + +keyboard-shortcuts = Keyboard Shortcuts + .desc = View and customize shortcuts + +## Input: Mouse +mouse = Mouse + .desc = Mouse speed, acceleration, natural scrolling. + .primary-button = Primary button + .primary-button-left = Left + .primary-button-right = Right + .speed = Mouse speed + .acceleration = Enable mouse acceleration + .acceleration-desc = Automatically adjusts tracking sensitivty based on speed. + .double-click-speed = Double-click speed + .double-click-speed-desc = Changes how fast double-clicks have to be to register. + +mouse-scrolling = Scrolling + .speed = Scrolling speed + .natural = Natural scrolling + .natural-desc = Scroll the content, instead of the view + +## Input: Touchpad + +touchpad = Touchpad + .desc = Touchpad speed, click options, gestures. + .primary-button = Primary button + .primary-button-left = Left + .primary-button-right = Right + .speed = Touchpad speed + .acceleration = Enable touchpad acceleration + .acceleration-desc = Automatically adjusts tracking sensitivty based on speed. + .double-click-speed = Double-click speed + .double-click-speed-desc = Changes how fast double-clicks have to be to register. diff --git a/page/src/binder.rs b/page/src/binder.rs index ace3e93..1947a29 100644 --- a/page/src/binder.rs +++ b/page/src/binder.rs @@ -118,19 +118,22 @@ impl Binder { self.page.get_mut(id).map(AsMut::as_mut) } + /// Get entity ID of page by its type ID. + pub fn page_id>(&self) -> Option { + self.typed_page_ids.get(&TypeId::of::

()).copied() + } + /// Obtain a reference to a page by its type ID. #[must_use] pub fn page>(&self) -> Option<&P> { - let id = self.typed_page_ids.get(&TypeId::of::

())?; - let page = self.page.get(*id)?; + let page = self.page.get(self.page_id::

()?)?; page.downcast_ref::

() } /// Obtain a reference to a page by its type ID. #[must_use] pub fn page_mut>(&mut self) -> Option<&mut P> { - let id = self.typed_page_ids.get(&TypeId::of::

())?; - let page = self.page.get_mut(*id)?; + let page = self.page.get_mut(self.page_id::

()?)?; page.downcast_mut::

() }