From 3ced5692873ae09667af8cebe582ccff209df19e Mon Sep 17 00:00:00 2001 From: Andrey Kutejko Date: Sun, 14 Mar 2021 17:08:46 +0100 Subject: [PATCH] Add proc macro for binding methods to action maps --- .github/workflows/CI.yml | 8 + Cargo.toml | 1 + examples/Cargo.toml | 3 + examples/src/bin/actions.rs | 333 +++++++++++++++++++++++ gio-macros/COPYRIGHT | 1 + gio-macros/Cargo.toml | 24 ++ gio-macros/LICENSE | 1 + gio-macros/src/actions.rs | 527 ++++++++++++++++++++++++++++++++++++ gio-macros/src/lib.rs | 201 ++++++++++++++ gio-macros/tests/actions.rs | 195 +++++++++++++ gio/Cargo.toml | 1 + gio/src/lib.rs | 1 + 12 files changed, 1296 insertions(+) create mode 100644 examples/src/bin/actions.rs create mode 120000 gio-macros/COPYRIGHT create mode 100644 gio-macros/Cargo.toml create mode 120000 gio-macros/LICENSE create mode 100644 gio-macros/src/actions.rs create mode 100644 gio-macros/src/lib.rs create mode 100644 gio-macros/tests/actions.rs diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 25797773ad03..6f6ca6baaf46 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -59,6 +59,9 @@ jobs: - run: cargo run --release -- --no-manual-traits ../glib-macros working-directory: checker if: matrix.rust == 'nightly' && matrix.conf.name == 'glib' + - run: cargo run --release -- --no-manual-traits ../gio-macros + working-directory: checker + if: matrix.rust == 'nightly' && matrix.conf.name == 'gio' - run: cargo run --release -- --no-manual-traits ../gtk3-macros working-directory: checker if: matrix.rust == 'nightly' && matrix.conf.name == 'glib' @@ -135,6 +138,11 @@ jobs: run: xvfb-run --auto-servernum cargo test --manifest-path glib-macros/Cargo.toml - name: "glib-macros: build" run: cargo build --manifest-path glib-macros/Cargo.toml + # gio-macros + - name: "gio-macros: tests" + run: cargo test --manifest-path gio-macros/Cargo.toml + - name: "gio-macros: build" + run: cargo build --manifest-path gio-macros/Cargo.toml # examples - name: "examples" run: cargo build --manifest-path examples/Cargo.toml --bins --examples --all-features diff --git a/Cargo.toml b/Cargo.toml index 4d013a579c55..7d0f2d203382 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "gdkx11/sys", "gio", "gio/sys", + "gio-macros", "glib", "glib/gobject-sys", "glib/sys", diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 39d1f860cd16..65303b6f9a6b 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -31,6 +31,9 @@ optional = true [build-dependencies.gio] path = "../gio" +[[bin]] +name = "actions" + [[bin]] name = "cairo_png" required-features = ["cairo"] diff --git a/examples/src/bin/actions.rs b/examples/src/bin/actions.rs new file mode 100644 index 000000000000..89bff127e0b8 --- /dev/null +++ b/examples/src/bin/actions.rs @@ -0,0 +1,333 @@ +//! # Actions Sample +//! +//! This sample demonstrates how to use `gio::actions` macro. + +use gtk::prelude::*; +use gtk::{gio, glib}; + +use std::env::args; + +#[derive(glib::Downgrade)] +pub struct ExampleApplication(gtk::Application); + +impl ExampleApplication { + pub fn new() -> Self { + let app = Self( + gtk::ApplicationBuilder::new() + .application_id("com.github.gtk-rs.examples.actions") + .build(), + ); + app.0.connect_startup(glib::clone!(@weak app => move |_app| + app.startup() + )); + app.0.connect_activate(glib::clone!(@weak app => move |_app| + app.activate() + )); + app + } + + fn startup(&self) { + // This line creates actions (`gio::SimpleAction`), + // binds them to handlers, defined in the next `impl` block + // and adds them to a `self.0`. + self.register_actions(&self.0); + self.add_accelerators(); + self.build_system_menu(); + } + + fn add_accelerators(&self) { + self.0.set_accels_for_action("app.about", &["F1"]); + self.0.set_accels_for_action("app.quit", &["Q"]); + } + + fn activate(&self) { + if let Some(window) = self.0.get_active_window() { + window.show_all(); + } else { + let window = ExampleWindow::new(&self.0); + window.0.show_all(); + } + } + + fn build_system_menu(&self) { + let menu = gio::Menu::new(); + menu.append(Some("Quit"), Some("app.quit")); + self.0.set_app_menu(Some(&menu)); + + let menu_bar = gio::Menu::new(); + menu_bar.append_submenu(Some("_Actions"), &{ + let menu = gio::Menu::new(); + menu.append(Some("Example Action"), Some("win.example_action")); + menu.append(Some("Full screen"), Some("win.fullscreen")); + menu.append(Some("Quit"), Some("app.quit")); + menu + }); + menu_bar.append_submenu(Some("_Group #1"), &{ + let menu = gio::Menu::new(); + menu.append(Some("Example Action"), Some("group1.example_action")); + menu + }); + menu_bar.append_submenu(Some("_Group #2"), &{ + let menu = gio::Menu::new(); + menu.append(Some("Example Action"), Some("group2.example_action")); + menu + }); + menu_bar.append_submenu(Some("?"), &{ + let menu = gio::Menu::new(); + menu.append(Some("About"), Some("app.about")); + menu + }); + self.0.set_menubar(Some(&menu_bar)); + } +} + +impl std::default::Default for ExampleApplication { + fn default() -> Self { + Self::new() + } +} + +#[gio::actions] +impl ExampleApplication { + fn about(&self) { + let window = self.0.get_active_window(); + let dialog = gtk::AboutDialogBuilder::new() + .website_label("gtk-rs") + .website("http://gtk-rs.org") + .authors(vec!["Gtk-rs developers".into()]) + .title("About!") + .build(); + dialog.set_transient_for(window.as_ref()); + dialog.show_all(); + } + + fn quit(&self) { + // Close all windows + for window in self.0.get_windows() { + window.close(); + } + } +} + +#[derive(glib::Downgrade)] +pub struct ExampleWindow(gtk::ApplicationWindow); + +impl ExampleWindow { + pub fn new(app: >k::Application) -> Self { + let window = Self( + gtk::ApplicationWindowBuilder::new() + .application(app) + .title("System menu bar") + .window_position(gtk::WindowPosition::Center) + .default_width(350) + .default_height(70) + .build(), + ); + + let grid = gtk::GridBuilder::new() + .margin(10) + .row_spacing(10) + .column_spacing(10) + .build(); + window.0.add(&grid); + + let label = gtk::LabelBuilder::new() + .label("Nothing happened yet") + .halign(gtk::Align::Start) + .build(); + grid.attach(&label, 0, 0, 2, 1); + + let example = gtk::ButtonBuilder::new() + .label("Example Action") + .action_name("win.example_action") + .build(); + grid.attach(&example, 0, 1, 1, 1); + + let label = gtk::LabelBuilder::new() + .label("Toggle full screen mode") + .halign(gtk::Align::Start) + .build(); + grid.attach(&label, 0, 2, 1, 1); + + let switch = gtk::Switch::new(); + switch.set_action_name(Some("win.fullscreen")); + grid.attach(&switch, 1, 2, 1, 1); + + let colors = RadioGroupBuilder::new("win.pick_color") + .add("Red", "#FF0000") + .add("Green", "#00FF00") + .add("Blue", "#0000FF") + .build(); + grid.attach(&colors, 0, 3, 1, 1); + + // This line creates actions (`gio::SimpleAction`), + // binds them to handlers, defined in the next `impl` block + // and adds them to a `window.0`. + window.register_actions(&window.0); + + // Get "volume" action from the window. It exists after an invocation + // of `register_action` method, which created all actions and added them + // to our window. + let volume_action = window.0.lookup_action("volume").unwrap(); + + let volume = gtk::ScaleBuilder::new() + .orientation(gtk::Orientation::Horizontal) + .adjustment(>k::Adjustment::new(0.0, -2.0, 12.0, 0.1, 0.0, 0.0)) + .build(); + volume.connect_change_value( + glib::clone!(@weak window, @weak volume_action => move |_scale, _scroll_type, value| { + volume_action.activate(Some(&value.to_variant())); + Inhibit(false) + }), + ); + grid.attach(&volume, 0, 4, 1, 1); + + let volume_label = gtk::LabelBuilder::new() + .label("Volume: 0.0") + .halign(gtk::Align::End) + .build(); + grid.attach(&volume_label, 1, 4, 1, 1); + + volume_action.connect_property_state_notify( + glib::clone!(@weak volume_label => move |action| { + let value = action.get_state().and_then(|variant| variant.get::()).unwrap(); + volume_label.set_label(&format!("Volume: {:.2}", value)); + }), + ); + + // Additionally, we can create own action groups and add them into our window. + // All actions in this group have a prefix "group1". + let action_group1 = gio::SimpleActionGroup::new(); + window.register_actions_group1(&action_group1); + window.0.insert_action_group("group1", Some(&action_group1)); + + // And all actions in this group have a prefix "group2". + let action_group2 = gio::SimpleActionGroup::new(); + window.register_actions_group2(&action_group2); + window.0.insert_action_group("group2", Some(&action_group2)); + + window + } + + fn show_message(&self, message: &str) { + let dialog = gtk::MessageDialogBuilder::new() + .transient_for(&self.0) + .modal(true) + .message_type(gtk::MessageType::Info) + .text(message) + .buttons(gtk::ButtonsType::Ok) + .build(); + + dialog.show_all(); + dialog.run(); + dialog.close(); + } +} + +#[gio::actions] +impl ExampleWindow { + fn example_action(&self) { + self.show_message("Example Action is activated!"); + } + + #[action(stateful)] + fn fullscreen(&self, was_active: bool) -> Option { + let is_active = !was_active; // New state + if is_active { + self.0.fullscreen(); + } else { + self.0.unfullscreen(); + } + // Update state of the action by returning new value + Some(is_active) + } + + // This action has both a state and a parameter. + #[action(stateful)] + fn pick_color(&self, previous_state: String, color_parameter: String) -> Option { + if previous_state == color_parameter { + // We do not want to change the state to the same value. So, returning `None`. + None + } else { + // Color is different, so let's update the state. + Some(color_parameter) + } + } + + #[action(name = "volume")] + fn volume_activate(&self, _state: f64, value: f64) -> Option { + Some(value) + } + + #[action(change_state)] + fn volume(&self, value: f64) -> Option { + if value >= 0.0 && value <= 10.0 { + // accept + Some(value) + } else { + // reject value + None + } + } +} + +// This `impl` block also contains actions, but its register method is +// renamed by a following annotation to `register_actions_group1` not to +// collide with a block above. +#[gio::actions(register_fn = "register_actions_group1")] +impl ExampleWindow { + // We can rename an action, so its detailed name becomes `group1.example_action`. + #[action(name = "example_action")] + fn group1_example_action(&self) { + self.show_message("Group #1 Example Action is activated!"); + } +} + +#[gio::actions(register_fn = "register_actions_group2")] +impl ExampleWindow { + // We can rename an action, so its detailed name becomes `group2.example_action`. + #[action(name = "example_action")] + fn group2_example_action(&self) { + self.show_message("Group #2 Example Action is activated!"); + } +} + +struct RadioGroupBuilder { + grid: gtk::Grid, + count: i32, + last: Option, + action: String, +} + +impl RadioGroupBuilder { + pub fn new(action: &str) -> Self { + Self { + grid: gtk::GridBuilder::new().row_spacing(10).build(), + count: 0, + last: None, + action: action.into(), + } + } + + pub fn add(mut self, label: &str, parameter: &str) -> Self { + let radio = gtk::RadioButtonBuilder::new() + .label(label) + .action_name(&self.action) + .action_target(¶meter.to_variant()) + .build(); + radio.join_group(self.last.as_ref()); + self.grid.attach(&radio, 0, self.count, 1, 1); + self.last = Some(radio); + self.count += 1; + self + } + + pub fn build(self) -> gtk::Widget { + self.grid.upcast() + } +} + +fn main() { + let application = ExampleApplication::new(); + application.0.run(&args().collect::>()); +} diff --git a/gio-macros/COPYRIGHT b/gio-macros/COPYRIGHT new file mode 120000 index 000000000000..dc5f40a221a3 --- /dev/null +++ b/gio-macros/COPYRIGHT @@ -0,0 +1 @@ +../COPYRIGHT \ No newline at end of file diff --git a/gio-macros/Cargo.toml b/gio-macros/Cargo.toml new file mode 100644 index 000000000000..f743716d600a --- /dev/null +++ b/gio-macros/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "gio-macros" +documentation = "https://gtk-rs.org/docs/gio-macros/" +homepage = "https://gtk-rs.org/" +authors = ["The Gtk-rs Project Developers"] +description = "Rust bindings for the Gio library, proc macros crate" +version = "0.13.0" +keywords = ["glib", "gtk-rs", "gnome", "GUI"] +repository = "https://github.com/gtk-rs/gtk-rs" +license = "MIT" +edition = "2018" + +[dependencies] +syn = { version = "1", features = ["full"] } +quote = "1" +proc-macro2 = "1" +darling = "0.12" + +[lib] +proc-macro = true + +[dev-dependencies] +glib = { path = "../glib" } +gio = { path = "../gio" } diff --git a/gio-macros/LICENSE b/gio-macros/LICENSE new file mode 120000 index 000000000000..ea5b60640b01 --- /dev/null +++ b/gio-macros/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/gio-macros/src/actions.rs b/gio-macros/src/actions.rs new file mode 100644 index 000000000000..ade28e801cfa --- /dev/null +++ b/gio-macros/src/actions.rs @@ -0,0 +1,527 @@ +// Take a look at the license at the top of the repository in the LICENSE file. + +use darling::{util::Flag, FromMeta}; +use proc_macro::TokenStream; +use proc_macro2::{Span, TokenStream as TokenStream2}; +use quote::{format_ident, quote, quote_spanned}; +use std::collections::BTreeMap; +use syn::{ + parse, spanned::Spanned, Attribute, Error, FnArg, ImplItem, ImplItemMethod, ItemImpl, Lit, + Meta, MetaList, NestedMeta, PatType, Signature, Type, +}; + +#[derive(Debug, Default, FromMeta)] +#[darling(default)] +pub struct ActionImplAttributes { + register_fn: Option, +} + +#[derive(Debug, Default, FromMeta)] +#[darling(default)] +struct ActionAttributes { + name: Option, + stateful: Flag, + initial_state: Option, + change_state: Flag, + no_parameter: Flag, +} + +#[derive(Debug)] +struct ActionHandlerInfo { + attrs: ActionAttributes, + sig: Signature, +} + +impl ActionHandlerInfo { + fn action_name(&self) -> String { + if let Some(ref name) = self.attrs.name { + name.clone() + } else { + self.sig.ident.to_string() + } + } +} + +fn expect_one( + mut vec: Vec, + make_item_error: impl Fn(&T) -> Error, +) -> Result, Error> { + match vec.len() { + 0 => Ok(None), + 1 => Ok(Some(vec.remove(0))), + _ => { + let mut error_acc = None; + for item in &vec[1..] { + let item_error = (make_item_error)(item); + combine_errors(&mut error_acc, item_error); + } + Err(error_acc.unwrap()) + } + } +} + +struct ActivateHandler { + sig: Signature, + state_arg_index: Option, + parameter_arg_index: Option, +} + +impl ActivateHandler { + fn try_from_signature(sig: Signature, stateful: bool) -> Result { + if !is_assoc(&sig) { + return Err(Error::new( + sig.span(), + "Unsupported signature of method. Only associated methods are supported.", + )); + } + + let state_arg_index; + let parameter_arg_index; + match sig.inputs.len() { + 1 => { + state_arg_index = None; + parameter_arg_index = None; + }, + 2 if stateful => { + state_arg_index = Some(1); + parameter_arg_index = None; + }, + 2 => { + state_arg_index = None; + parameter_arg_index = Some(1); + }, + 3 if stateful => { + state_arg_index = Some(1); + parameter_arg_index = Some(2); + }, + n => return Err(Error::new( + sig.span(), + format!("Unsupported signature of method. It has {} parameters but only 0, 1, or 2 are supported.", n - 1) + )), + } + + Ok(Self { + sig, + state_arg_index, + parameter_arg_index, + }) + } + + fn state_arg(&self) -> Option<&FnArg> { + self.state_arg_index.map(|index| &self.sig.inputs[index]) + } + + fn parameter_arg(&self) -> Option<&FnArg> { + self.parameter_arg_index + .map(|index| &self.sig.inputs[index]) + } + + fn state_type(&self) -> Result, Error> { + self.state_arg().map(|arg| argument_type(arg)).transpose() + } + + fn parameter_type(&self) -> Result, Error> { + self.parameter_arg() + .map(|arg| argument_type(arg)) + .transpose() + } +} + +struct ChangeStateHandler { + sig: Signature, + no_parameter: bool, +} + +impl ChangeStateHandler { + fn try_from_signature(sig: Signature, no_parameter: bool) -> Result { + if !is_assoc(&sig) { + return Err(Error::new( + sig.span(), + "Unsupported signature of a method. Only associated methods are supported.", + )); + } + if sig.inputs.len() != 2 { + return Err(Error::new( + sig.span(), + format!( + "Unsupported signature of a method. It has {} parameters but only 1 is supported.", + sig.inputs.len() - 1 + ), + )); + } + Ok(Self { sig, no_parameter }) + } + + fn state_arg(&self) -> &FnArg { + &self.sig.inputs[1] + } + + fn state_type(&self) -> Result<&Type, Error> { + argument_type(self.state_arg()) + } +} + +struct ActionInfo { + name: String, + initial_state: Option, + activate_handler: Option, + change_state_handler: Option, +} + +impl ActionInfo { + fn try_from_handlers(name: String, handlers: Vec) -> Result { + let (change_state_handlers, activate_handlers): (Vec<_>, Vec<_>) = handlers + .into_iter() + .partition(|h| h.attrs.change_state.into()); + + let mut activate_handler = expect_one(activate_handlers, |handler| { + Error::new( + handler.sig.span(), + format!("Duplicated `activate` handler for an action {}.", name), + ) + })?; + + let mut change_state_handler = expect_one(change_state_handlers, |handler| { + Error::new( + handler.sig.span(), + format!("Duplicated `change-state` handler for an action {}.", name), + ) + })?; + + let initial_state = match ( + activate_handler + .as_mut() + .and_then(|h| h.attrs.initial_state.take()), + change_state_handler + .as_mut() + .and_then(|h| h.attrs.initial_state.take()), + ) { + (None, None) => None, + (v @ Some(..), None) | (None, v @ Some(..)) => v, + (Some(..), Some(value)) => { + return Err(Error::new( + value.span(), + format!("Duplicated initial state for an action {}.", name), + )) + } + }; + + let stateful = activate_handler + .as_ref() + .map_or(false, |h| h.attrs.stateful.into()) + || initial_state.is_some() + || change_state_handler.is_some(); + + let activate_handler = activate_handler + .map(|h| ActivateHandler::try_from_signature(h.sig, stateful)) + .transpose()?; + + let no_parameter = change_state_handler + .as_ref() + .map_or(false, |h| h.attrs.no_parameter.into()); + let change_state_handler = change_state_handler + .map(|h| ChangeStateHandler::try_from_signature(h.sig, no_parameter)) + .transpose()?; + + Ok(Self { + name, + initial_state, + activate_handler, + change_state_handler, + }) + } + + fn state_type(&self) -> Result, Error> { + if let Some(ref h) = self.change_state_handler { + h.state_type().map(Some) + } else if let Some(ref h) = self.activate_handler { + h.state_type() + } else { + Ok(None) + } + } + + fn parameter_type(&self) -> Result, Error> { + match (&self.activate_handler, &self.change_state_handler) { + (Some(ref handler), _) => handler.parameter_type(), + (None, Some(ref handler)) if handler.no_parameter => Ok(None), + (None, Some(ref handler)) => handler.state_type().map(Some), + (None, None) => Ok(None), + } + } +} + +fn get_parameter(info: &ActionInfo, arg: &FnArg) -> Result { + let action_name = &info.name; + let parameter_type = argument_type(arg)?; + Ok(quote_spanned! { arg.span() => + match parameter.and_then(|variant| <#parameter_type as glib::variant::FromVariant>::from_variant(variant)) { + Some(parameter) => parameter, + None => { + glib::g_critical!("actions", "Action {} expects a parameter of type {} but received `{:?}`.", #action_name, stringify!(#parameter_type), parameter); + return; + }, + } + }) +} + +fn get_state(info: &ActionInfo, arg: &FnArg) -> Result { + let action_name = &info.name; + let state_type = argument_type(arg)?; + Ok(quote_spanned! { arg.span() => + match action.get_state().and_then(|variant| variant.get::<#state_type>()) { + Some(value) => value, + None => { + glib::g_critical!("actions", "Action {} expects a state of type {} but has `{:?}`.", #action_name, stringify!(#state_type), action.get_state()); + return; + } + } + }) +} + +fn change_state(span: Span, expression: &TokenStream2, state_type: &Type) -> TokenStream2 { + quote_spanned! { span => { + let new_state_opt: Option<#state_type> = (#expression).into(); + if let Some(ref new_state) = new_state_opt { + action.change_state(&<#state_type as glib::variant::ToVariant>::to_variant(new_state)); + } + }} +} + +fn add_comma(expression: TokenStream2) -> TokenStream2 { + quote! { #expression , } +} + +fn generate_activate_handler( + info: &ActionInfo, + handler: &ActivateHandler, +) -> Result { + let state_arg = handler + .state_arg() + .map(|arg| get_state(info, arg)) + .transpose()? + .map(add_comma); + + let parameter_arg = handler + .parameter_arg() + .map(|arg| get_parameter(info, arg)) + .transpose()? + .map(add_comma); + + let method = &handler.sig.ident; + let mut invoke = quote_spanned! { handler.sig.span() => + this.#method(#state_arg #parameter_arg) + }; + + if let Some(ref state_type) = handler.state_type()? { + invoke = change_state(handler.sig.output.span(), &invoke, state_type); + } + + let handler = quote_spanned! { handler.sig.span() => + #[allow(unused_variables)] + action.connect_activate( + glib::clone!(@weak self as this => move |action, parameter| #invoke) + ); + }; + Ok(handler) +} + +fn generate_change_state_handler( + info: &ActionInfo, + handler: &ChangeStateHandler, +) -> Result { + let action_name = &info.name; + let method = &handler.sig.ident; + let state_type = handler.state_type()?; + Ok(quote_spanned! { handler.sig.span() => + #[allow(unused_variables)] + action.connect_change_state(glib::clone!(@weak self as this => move |action, new_state_opt| { + let new_state: #state_type = match new_state_opt.and_then(|state| state.get()) { + Some(value) => value, + None => { + glib::g_critical!("actions", "State of type {} is expected in action {} but it is None.", stringify!(#state_type), #action_name); + return; + } + }; + let result: Option<#state_type> = this.#method(new_state).into(); + if let Some(ref new_state) = result { + action.set_state(&<#state_type as glib::variant::ToVariant>::to_variant(new_state)); + } + })); + }) +} + +fn generate_action(info: &ActionInfo) -> Result { + let action_name = &info.name; + + let parameter = if let Some(parameter_type) = info.parameter_type()? { + quote_spanned! { parameter_type.span() => + Some(<#parameter_type as glib::variant::StaticVariantType>::static_variant_type().as_ref()) + } + } else { + quote! { + None + } + }; + + let create = if let Some(state_type) = info.state_type()? { + let initial_state_expr = if let Some(ref value) = info.initial_state { + quote_spanned! { value.span() => + <#state_type as std::convert::From<_>>::from(#value) + } + } else { + quote_spanned! { state_type.span() => + <#state_type as std::default::Default>::default() + } + }; + quote! { + let initial_state: #state_type = #initial_state_expr; + let initial_state_variant = <#state_type as glib::variant::ToVariant>::to_variant(&initial_state); + let action = gio::SimpleAction::new_stateful(#action_name, #parameter, &initial_state_variant); + } + } else { + quote! { + let action = gio::SimpleAction::new(#action_name, #parameter); + } + }; + + let activate_handler = info + .activate_handler + .as_ref() + .map(|handler| generate_activate_handler(info, handler)) + .transpose()?; + + let change_state_handler = info + .change_state_handler + .as_ref() + .map(|handler| generate_change_state_handler(info, handler)) + .transpose()?; + + let action = quote! { + { + #create + #activate_handler + #change_state_handler + action + } + }; + Ok(action) +} + +fn combine_errors(error_acc: &mut Option, error: Error) { + match error_acc { + Some(ref mut error_acc) => { + error_acc.combine(error); + } + None => { + error_acc.replace(error); + } + } +} + +fn attributes_to_metas(attributes: Vec) -> Result, Error> { + let mut metas = Vec::new(); + let mut error = None; + for attr in attributes { + let meta = attr.parse_meta()?; + match meta { + Meta::List(MetaList { nested, .. }) => metas.extend(nested), + _ => combine_errors(&mut error, Error::new(attr.span(), "Unexpected attribute")), + } + } + if let Some(error) = error { + Err(error) + } else { + Ok(metas) + } +} + +fn is_assoc(sig: &Signature) -> bool { + sig.inputs + .first() + .map_or(false, |arg| matches!(arg, FnArg::Receiver(..))) +} + +fn argument_type(arg: &FnArg) -> Result<&Type, Error> { + match arg { + FnArg::Typed(PatType { ty, .. }) => Ok(&*ty), + _ => Err(Error::new( + arg.span(), + "Cannot extract type of an argument.", + )), + } +} + +fn generate_register_method( + attrs: &ActionImplAttributes, + actions: &[TokenStream2], +) -> ImplItemMethod { + let register_fn = format_ident!( + "{}", + attrs.register_fn.as_deref().unwrap_or("register_actions") + ); + let register_method = quote! { + #[allow(clippy)] + fn #register_fn>(&self, map: &AM) { + #( + map.add_action(& #actions ); + )* + } + }; + parse(register_method.into()).unwrap() +} + +pub fn actions( + attrs: ActionImplAttributes, + mut input: ItemImpl, +) -> Result { + let mut action_handlers: BTreeMap> = BTreeMap::new(); + for item in input.items.iter_mut() { + if let ImplItem::Method(method) = item { + let attributes = + extract_from_vec(&mut method.attrs, |attr| attr.path.is_ident("action")); + let metas = attributes_to_metas(attributes).map_err(|err| err.to_compile_error())?; + let info = ActionHandlerInfo { + attrs: ActionAttributes::from_list(&metas) + .map_err(|err| TokenStream::from(err.write_errors()))?, + sig: method.sig.clone(), + }; + action_handlers + .entry(info.action_name()) + .or_default() + .push(info); + } + } + + let action_infos: Vec = action_handlers + .into_iter() + .map(|(name, handlers)| ActionInfo::try_from_handlers(name, handlers)) + .collect::>() + .map_err(|err| err.to_compile_error())?; + + let action_definitions: Vec = action_infos + .iter() + .map(generate_action) + .collect::, _>>() + .map_err(|err| err.to_compile_error())?; + + let register_method = generate_register_method(&attrs, &action_definitions); + input.items.push(ImplItem::Method(register_method)); + + Ok(quote!(#input).into()) +} + +// TODO: Replace this by Vec::drain_filter as soon as it is stabilized. +fn extract_from_vec(vec: &mut Vec, predicate: impl Fn(&T) -> bool) -> Vec { + let mut i = 0; + let mut result: Vec = Vec::new(); + while i != vec.len() { + if (predicate)(&vec[i]) { + let item = vec.remove(i); + result.push(item); + } else { + i += 1; + } + } + result +} diff --git a/gio-macros/src/lib.rs b/gio-macros/src/lib.rs new file mode 100644 index 000000000000..99ac1e7e61dd --- /dev/null +++ b/gio-macros/src/lib.rs @@ -0,0 +1,201 @@ +// Take a look at the license at the top of the repository in the LICENSE file. + +extern crate proc_macro; + +mod actions; + +use darling::FromMeta; +use proc_macro::TokenStream; +use syn::{parse_macro_input, AttributeArgs, ItemImpl}; + +/// Macro for creating [`gio::Action`]s and registering them in a given +/// [`gio::ActionMap`]. It generates a method `register_actions` with +/// a following signature: +/// +/// ```rust +/// # struct X; impl X { +/// fn register_actions>(&self, map: &AM) +/// # {} } +/// ``` +/// +/// ## Name of an action +/// +/// Name of an action is the same as the name of a function. This can be +/// changed by an annotation. +/// +/// ```rust +/// # use gio::ActionMapExt; +/// # #[derive(glib::Downgrade)] +/// # pub struct X; +/// # #[gio_macros::actions] +/// # impl X { +/// // Name of the action is "play". +/// fn play(&self) { +/// } +/// +/// // Name of the action is "pause". +/// #[action(name = "pause")] +/// fn pause_handler(&self) { +/// } +/// # } +/// ``` +/// +/// ## Stateful action +/// +/// An action may have a state. A handler need to be annotated accordingly. +/// A handler must have a following signature: +/// +/// ```rust,ignore +/// #[action(stateful, initial_state = )] +/// fn action_handler(&self, state: StateType) -> Option +/// ``` +/// +/// `StateType` must implement [`glib::variant::StaticVariantType`]. Returning +/// `Some(value)` from such handler triggers a change of an action's state. +/// +/// An `initial_state` annotation may be omited. In this case `StateType` must +/// implement [`std::default::Default`], which is used to initialize an action. +/// +/// ## Parameter +/// +/// An action may have a parameter. In this case a handler need to have one of +/// the following signatures: +/// +/// ```rust,ignore +/// // Stateless action +/// fn action_handler(&self, parameter: ParameterType) +/// +/// // Stateful action +/// #[action(stateful)] +/// fn action_handler(&self, state: StateType, parameter: ParameterType) -> Option +/// ``` +/// +/// `ParameterType` must also implement [`glib::variant::StaticVariantType`]. +/// +/// ## Change state handler +/// +/// A handler may be annotated as `change_state`. In this case it will be bound +/// to a `change-state` signal of an action (in opposite to an `activate` +/// signal used by default). In this case a signature should look like this: +/// +/// ```rust,ignore +/// // Stateful action +/// #[action(stateful, change_state)] +/// fn change_state_action_handler(&self, state: StateType) -> Option +/// ``` +/// +/// If you need an action with both `activate` and `change-state` handlers, just +/// create two methods and annotate them with the same action name. +/// +/// ## Defaults +/// +/// A `stateful` annotation may be omited if `initial_state` or `change_state` is +/// specified. +/// +/// # Example +/// ```rust +/// use gio::prelude::*; +/// +/// #[derive(glib::Downgrade)] +/// pub struct MyApplication(gio::Application); +/// +/// impl MyApplication { +/// pub fn new() -> Self { +/// let app = Self(gio::Application::new( +/// Some("com.example.MyApplication"), +/// gio::ApplicationFlags::FLAGS_NONE, +/// )); +/// app.register_actions(&app.0); +/// app +/// } +/// } +/// +/// #[gio::actions] +/// impl MyApplication { +/// fn action1(&self) { +/// // handle "action1" +/// } +/// +/// // Explicitely specify action name +/// #[action(name = "action_second")] +/// fn action2(&self) { +/// // handle "action_second" +/// } +/// +/// // Action with a parameter +/// fn action3(&self, param: String) { +/// // handle "action3" +/// } +/// +/// // Stateful action with a specified initial state +/// #[action(stateful, initial_state = false)] +/// fn stateful_toggle(&self, state: bool) -> Option { +/// // handle action +/// Some(!state) // return new state +/// } +/// +/// // Stateful action with a default initial state (`false`) +/// #[action(stateful)] +/// fn stateful_toggle_default(&self, state: bool) -> Option { +/// Some(!state) // return new state +/// } +/// +/// // Stateful action with a state of `String` type +/// #[action(stateful, initial_state = "")] +/// fn stateful_text(&self, state: String) -> Option { +/// if state.len() >= 10 { +/// None // do not change state +/// } else { +/// Some(state + "!") // change state +/// } +/// } +/// +/// // Stateful action with a `String` parameter +/// #[action(stateful, initial_state = true)] +/// fn stateful_toggle_with_parameter(&self, state: bool, param: String) -> Option { +/// // Do not change the state of the action +/// None +/// } +/// +/// // Stateful action with a handler for a `change-state` signal +/// #[action(stateful, initial_state = 0.0_f64, change_state)] +/// fn volume(&self, value: f64) -> Option { +/// if value >= 0.0 && value <= 10.0 { +/// // accept new state +/// Some(value) +/// } else { +/// // reject +/// None +/// } +/// } +/// +/// // Stateful action with a handler for a `change-state` signal and a parameter of the same type. +/// // `change_state` also implies `stateful`, so it may be omitted. +/// #[action(initial_state = true, change_state)] +/// fn pause(&self, paused: bool) -> Option { +/// Some(paused) +/// } +/// +/// // Stateful action with a handler for a `change-state` signal and without a parameter. +/// #[action(change_state, initial_state = true, no_parameter)] +/// fn pause_no_param(&self, paused: bool) -> Option { +/// Some(paused) +/// } +/// } +/// ``` +/// +/// [`gio::Action`]: struct.Action.html +/// [`gio::ActionMap`]: struct.ActionMap.html +/// [`glib::variant::StaticVariantType`]: ../glib/variant/trait.StaticVariantType.html +#[proc_macro_attribute] +pub fn actions(args: TokenStream, item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as ItemImpl); + let attribute_args = parse_macro_input!(args as AttributeArgs); + let attrs = match actions::ActionImplAttributes::from_list(&attribute_args) { + Ok(v) => v, + Err(e) => { + return TokenStream::from(e.write_errors()); + } + }; + actions::actions(attrs, input).unwrap_or_else(|err| err) +} diff --git a/gio-macros/tests/actions.rs b/gio-macros/tests/actions.rs new file mode 100644 index 000000000000..6d0203e00f17 --- /dev/null +++ b/gio-macros/tests/actions.rs @@ -0,0 +1,195 @@ +use gio::prelude::*; +use std::cell::RefCell; +use std::rc::Rc; + +#[derive(glib::Downgrade)] +pub struct MyActionGroup { + group: gio::SimpleActionGroup, + log: Rc>>, +} + +impl MyActionGroup { + pub fn new() -> Self { + let app = Self { + group: gio::SimpleActionGroup::new(), + log: Default::default(), + }; + app.register_actions(&app.group); + app + } +} + +impl std::default::Default for MyActionGroup { + fn default() -> Self { + Self::new() + } +} + +#[gio::actions] +impl MyActionGroup { + fn action1(&self) { + self.log.borrow_mut().push("action1".to_string()); + } + + #[action(name = "action_second")] + fn action2(&self) { + self.log.borrow_mut().push("action2".to_string()); + } + + fn action3(&self, param: String) { + self.log.borrow_mut().push(format!("action3: {}", param)); + } + + fn action4(&self, param: bool) { + self.log.borrow_mut().push(format!("action4: {}", param)); + } + + fn action5(&self, (param1, param2, param3, param4): (String, bool, String, i32)) { + self.log.borrow_mut().push(format!( + "action5: {} {} {} {}", + param1, param2, param3, param4 + )); + } + + #[action(stateful, initial_state = false)] + fn stateful_toggle(&self, state: bool) -> Option { + self.log + .borrow_mut() + .push(format!("stateful_toggle: {}", state)); + Some(!state) + } + + #[action(stateful)] + fn stateful_toggle_default(&self, state: bool) -> Option { + self.log + .borrow_mut() + .push(format!("stateful_toggle_default: {}", state)); + Some(!state) + } + + #[action(stateful, initial_state = "()")] + fn stateful_text(&self, state: String) -> Option { + self.log + .borrow_mut() + .push(format!("stateful_text: {}", state)); + Some(format!("({})", state)) + } + + #[action(stateful, initial_state = true)] + fn stateful_toggle_with_parameter(&self, state: bool, mut param: String) -> Option { + if state { + param = param.chars().rev().collect(); + } + self.log.borrow_mut().push(format!( + "stateful_toggle_with_parameter: {} {}", + state, param + )); + Some(!state) + } + + #[action(change_state, initial_state = true)] + fn change_state_with_inferred_parameter(&self, state: bool) -> Option { + self.log + .borrow_mut() + .push(format!("change_state_with_inferred_parameter: {}", state)); + Some(state) + } + + #[action(change_state, initial_state = true, no_parameter)] + fn change_state_without_parameter(&self, state: bool) -> Option { + self.log + .borrow_mut() + .push(format!("change_state_without_parameter: {}", state)); + Some(state) + } +} + +#[test] +fn test_actions() { + let app = MyActionGroup::new(); + app.group.activate_action("action1", None); + app.group.activate_action("action_second", None); + app.group + .activate_action("action3", Some(&"Hello".to_variant())); + app.group + .activate_action("action1", Some(&"Unexpected parameter".to_variant())); + app.group.activate_action("action3", None); // Missing parameter + app.group.activate_action("action1", None); + app.group + .activate_action("action4", Some(&false.to_variant())); + app.group + .activate_action("action5", Some(&("Hello", true, "World", 42).to_variant())); + + app.group.activate_action("stateful_toggle", None); + app.group.activate_action("stateful_toggle", None); + app.group.activate_action("stateful_toggle", None); + + app.group.activate_action("stateful_toggle_default", None); + app.group.activate_action("stateful_toggle_default", None); + app.group.activate_action("stateful_toggle_default", None); + + app.group.activate_action("stateful_text", None); + app.group.activate_action("stateful_text", None); + app.group.activate_action("stateful_text", None); + + app.group.activate_action( + "stateful_toggle_with_parameter", + Some(&"Hello".to_variant()), + ); + app.group.activate_action( + "stateful_toggle_with_parameter", + Some(&"World".to_variant()), + ); + app.group.activate_action( + "stateful_toggle_with_parameter", + Some(&"World".to_variant()), + ); + app.group.activate_action( + "stateful_toggle_with_parameter", + Some(&"Hello".to_variant()), + ); + app.group.activate_action( + "change_state_with_inferred_parameter", + Some(&false.to_variant()), + ); + app.group.activate_action( + "change_state_with_inferred_parameter", + Some(&true.to_variant()), + ); + app.group + .activate_action("change_state_without_parameter", None); + app.group + .activate_action("change_state_without_parameter", None); + app.group + .activate_action("change_state_without_parameter", None); + + assert_eq!( + app.log.borrow().as_ref(), + vec![ + "action1", + "action2", + "action3: Hello", + "action1", + "action4: false", + "action5: Hello true World 42", + "stateful_toggle: false", + "stateful_toggle: true", + "stateful_toggle: false", + "stateful_toggle_default: false", + "stateful_toggle_default: true", + "stateful_toggle_default: false", + "stateful_text: ()", + "stateful_text: (())", + "stateful_text: ((()))", + "stateful_toggle_with_parameter: true olleH", + "stateful_toggle_with_parameter: false World", + "stateful_toggle_with_parameter: true dlroW", + "stateful_toggle_with_parameter: false Hello", + "change_state_with_inferred_parameter: false", + "change_state_with_inferred_parameter: true", + "change_state_without_parameter: false", + "change_state_without_parameter: true", + "change_state_without_parameter: false", + ] + ); +} diff --git a/gio/Cargo.toml b/gio/Cargo.toml index 094a1a2a689d..bef984534669 100644 --- a/gio/Cargo.toml +++ b/gio/Cargo.toml @@ -52,6 +52,7 @@ futures-channel = "0.3" futures-io = "0.3" ffi = { package = "gio-sys", path = "sys" } glib = { path = "../glib" } +gio-macros = { path = "../gio-macros" } thiserror = "1" [dev-dependencies] diff --git a/gio/src/lib.rs b/gio/src/lib.rs index 60bfb1abcad8..d8d958cc8fb4 100644 --- a/gio/src/lib.rs +++ b/gio/src/lib.rs @@ -7,6 +7,7 @@ #![allow(deprecated)] pub use ffi; +pub use gio_macros::actions; pub use glib; mod app_info;