diff --git a/Cargo.toml b/Cargo.toml index f5e6210e..ea5afaff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,9 @@ name = "libcosmic" version = "0.1.0" edition = "2021" +[workspace] +members = ["crates/*"] + [dependencies] cascade = "1.0.0" gtk4 = { version = "0.4.4", features = ["v4_4"] } diff --git a/crates/component-system/Cargo.toml b/crates/component-system/Cargo.toml new file mode 100644 index 00000000..3ecfd18b --- /dev/null +++ b/crates/component-system/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "cosmic-component-system" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" + +[dependencies] +gtk4 = "0.4.4" +relm4-macros = "0.4.1" +tokio = { version = "1.15.0", features = ["sync"]} \ No newline at end of file diff --git a/crates/component-system/README.md b/crates/component-system/README.md new file mode 100644 index 00000000..0bd304de --- /dev/null +++ b/crates/component-system/README.md @@ -0,0 +1,93 @@ +# COSMIC Component System + +This library is a GTK4 GUI framework inspired by [Relm](https://github.com/antoyo/relm), which is inspired by [Elm](https://guide.elm-lang.org/architecture/). The philosophy for this framework is to isolate custom widgets into reusable components. You start with a custom `Model` type that implements `Component`, which is used to register a component with an optional argument. On registration, the model is used to construct the view and its widgets in the `init_view()` function. An event-handler is also spawned to handle events from both the component and any component emitting events to it. Those events are received and handled in the `update()` function. Both the `init_view()` and `update()` methods also have access to an outbound sender, which the caller can forward and consume however desired. See the examples directory for a demonstration of how to create a component. + +## Defining a Component + +The simplest way to define a component is to use the `component!()` macro. + +```rs +pub enum MyCustomInputMessage { + Variant1, + Variant2, +} + +component! { + // The `()` is the args parameter accepted by `init_view()` + // and `Component::register()`. + pub struct MyCustomModel(()) { + pub state: String, + } + + // The `gtk::Box` is the root widget returned in `init_view()`. + pub struct MyCustomWidgets(gtk::Box) { + description: gtk::Label, + } + + // The type of the input sender + type Input = MyCustomInputMessage; + + // The type of the output sender + type Output = (); + + // `self` is `MyCustomModel`, and must return `(MyCustomWidget, RootWidget)` + fn init_view(self, args, input, output) { + ccs::view! { + root = gtk::Box { + set_orientation: gtk::Orientation::Vertical, + + append: description = >k::Label { + + } + } + } + + (MyCustomWidgets { description }, root) + } + + // Where events are received, with `self` also being `MyCustomModel`, and + // `widgets` is `MyCustomInputMessage`. `event` is `MyCustonInputMessage`. + fn update(self, widgets, event, input, output) { + match event { + MyCustomInputMessage::Variant1 => { + + } + + MyCustomInputMessage::Variant2 => { + + } + } + } +} +``` + +Components can be created and have their output events forwarded: + +```rs +let counter = InfoButton::default() + .register("Clicked 0 times".into(), "Click".into()) + .forward(input.clone(), |event| match event { + InfoButtonOutput::Clicked => AppEvent::Increment + }); +``` + +The handle returned can be used to emit inputs to it, and to get the root widget. + +```rs +counter.emit(InfoButtonInput::SetDescription(format!("Clicked {} times", count))); + +box.append(counter.widget()); +``` + + +## See Also + +[Relm4](https://github.com/AaronErhardt/relm4) uses a similar approach, but closely follows the Elm model. This library was created as an alternative approach that makes developing reusable components with forwardable events simpler. + +## License + +Licensed under the [Mozilla Public License 2.0](https://choosealicense.com/licenses/mpl-2.0/). + +### Contribution + +Any contribution intentionally submitted for inclusion in the work by you shall be licensed under the Mozilla Public License 2.0 (MPL-2.0). \ No newline at end of file diff --git a/crates/component-system/examples/basic/components/app.rs b/crates/component-system/examples/basic/components/app.rs new file mode 100644 index 00000000..71b1b264 --- /dev/null +++ b/crates/component-system/examples/basic/components/app.rs @@ -0,0 +1,109 @@ +// Copyright 2022 System76 +// SPDX-License-Identifier: MPL-2.0 + +use crate::components::{InfoButton, InfoButtonInput, InfoButtonOutput}; +use ccs::*; +use gtk::prelude::*; + +/// An input event that is used to update the model. +pub enum AppEvent { + Destroy, + Increment, +} + +component! { + /// The model where component state is stored. + #[derive(Default)] + pub struct App(gtk::Application) { + pub counter: usize, + } + + /// Widgets that are initialized in the view. + pub struct AppWidgets(gtk::ApplicationWindow) { + list: gtk::ListBox, + destroyable: Option>, + counter: Handle, + } + + type Input = AppEvent; + type Output = (); + + fn init_view(self, app, input, _output) { + let button_group = gtk::SizeGroup::new(gtk::SizeGroupMode::Both); + + // Create an `InfoButton` component. + let destroyable = InfoButton::default() + .register((String::new(), "Destroy".into(), button_group.clone())) + .forward(input.clone(), |event| match event { + InfoButtonOutput::Clicked => AppEvent::Destroy, + }); + + // Instruct the component to update its description. + let _ = destroyable.emit(InfoButtonInput::SetDescription( + "Click this button to destroy me".into(), + )); + + // Create a counter component, too. + let counter = InfoButton::default() + .register(("Click me too".into(), "Click".into(), button_group)) + .forward(input.clone(), |event| match event { + InfoButtonOutput::Clicked => AppEvent::Increment, + }); + + // Construct the view for this component, attaching the component's widget. + ccs::view! { + window = gtk::ApplicationWindow { + set_application: Some(&app), + set_child = Some(>k::Box) { + set_halign: gtk::Align::Center, + set_size_request: args!(400, -1), + set_orientation: gtk::Orientation::Vertical, + + append: list = >k::ListBox { + set_selection_mode: gtk::SelectionMode::None, + set_hexpand: true, + + append: destroyable.widget(), + append: counter.widget(), + }, + } + } + } + + window.show(); + + ( + AppWidgets { + list, + counter, + destroyable: Some(destroyable), + }, + window, + ) + } + + /// Updates the view + fn update(self, widgets, event, _input, _output) { + match event { + AppEvent::Increment => { + self.counter += 1; + + widgets + .counter + .emit(InfoButtonInput::SetDescription(format!( + "Clicked {} times", + self.counter + ))); + } + + AppEvent::Destroy => { + // Components are kept alive by their root GTK widget. + if let Some(handle) = widgets.destroyable.take() { + if let Some(parent) = handle.widget().parent() { + widgets.list.remove(&parent); + } + } + } + } + } +} diff --git a/crates/component-system/examples/basic/components/info_button.rs b/crates/component-system/examples/basic/components/info_button.rs new file mode 100644 index 00000000..7c6282d4 --- /dev/null +++ b/crates/component-system/examples/basic/components/info_button.rs @@ -0,0 +1,69 @@ +// Copyright 2022 System76 +// SPDX-License-Identifier: MPL-2.0 + +use ccs::*; +use gtk::prelude::*; + +pub enum InfoButtonInput { + SetDescription(String), +} + +pub enum InfoButtonOutput { + Clicked, +} + +component! { + #[derive(Default)] + pub struct InfoButton((String, String, gtk::SizeGroup)) { + + } + + pub struct InfoButtonWidgets(gtk::Box) { + description: gtk::Label, + } + + type Input = InfoButtonInput; + type Output = InfoButtonOutput; + + fn init_view(self, args, _input, output) { + let (desc, button_label, sg) = args; + ccs::view! { + root = gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_margin_start: 20, + set_margin_end: 20, + set_margin_top: 8, + set_margin_bottom: 8, + set_spacing: 24, + + append: description = >k::Label { + set_label: &desc, + set_halign: gtk::Align::Start, + set_hexpand: true, + set_valign: gtk::Align::Center, + set_ellipsize: gtk::pango::EllipsizeMode::End, + }, + + append: button = >k::Button { + set_label: &button_label, + + connect_clicked(output) => move |_| { + let _ = output.send(InfoButtonOutput::Clicked); + } + } + } + } + + sg.add_widget(&button); + + (InfoButtonWidgets { description }, root) + } + + fn update(self, widgets, message, _input, _output) { + match message { + InfoButtonInput::SetDescription(value) => { + widgets.description.set_text(&value); + } + } + } +} diff --git a/crates/component-system/examples/basic/components/mod.rs b/crates/component-system/examples/basic/components/mod.rs new file mode 100644 index 00000000..cbe0678c --- /dev/null +++ b/crates/component-system/examples/basic/components/mod.rs @@ -0,0 +1,8 @@ +// Copyright 2022 System76 +// SPDX-License-Identifier: MPL-2.0 + +mod app; +mod info_button; + +pub use self::app::*; +pub use self::info_button::*; diff --git a/crates/component-system/examples/basic/main.rs b/crates/component-system/examples/basic/main.rs new file mode 100644 index 00000000..b551cabe --- /dev/null +++ b/crates/component-system/examples/basic/main.rs @@ -0,0 +1,15 @@ +// Copyright 2022 System76 +// SPDX-License-Identifier: MPL-2.0 + +extern crate cosmic_component_system as ccs; + +mod components; + +use self::components::App; +use ccs::Component; + +fn main() { + ccs::run(|app| { + App::default().register(app); + }); +} diff --git a/crates/component-system/src/lib.rs b/crates/component-system/src/lib.rs new file mode 100644 index 00000000..86d97e5a --- /dev/null +++ b/crates/component-system/src/lib.rs @@ -0,0 +1,214 @@ +// Copyright 2022 System76 +// SPDX-License-Identifier: MPL-2.0 + +mod macros; + +use gtk4::prelude::*; +use tokio::sync::mpsc; + +pub use gtk4 as gtk; +pub use relm4_macros::view; + +pub type Sender = mpsc::UnboundedSender; +pub type Receiver = mpsc::UnboundedReceiver; + +/// A newly-registered component which supports destructuring the handle +/// by forwarding or ignoring outputs from the component. +pub struct Registered, I, O> { + /// Handle to the component that was registered. + pub handle: Handle, + + /// The outputs being received by the component. + pub receiver: Receiver, +} + +impl, I: 'static, O: 'static> Registered { + /// Forwards output events to the designated sender. + pub fn forward X) + 'static>( + self, + sender: Sender, + transform: F, + ) -> Handle { + let Registered { handle, receiver } = self; + forward(receiver, sender, transform); + handle + } + + pub fn handle(self, mut func: F) -> Handle { + let Registered { + handle, + mut receiver, + } = self; + + spawn_local(async move { + while let Some(event) = receiver.recv().await { + func(event); + } + }); + + handle + } + + /// Ignore outputs from the component and take the handle. + pub fn ignore(self) -> Handle { + self.handle + } +} + +/// Handle to an active widget component in the system. +pub struct Handle { + /// The widget that this component manages. + widget: W, + + /// Used for emitting events to the component. + sender: Sender, +} + +impl Widget for Handle { + fn widget(&self) -> &W { + &self.widget + } +} + +impl Handle { + pub fn emit(&self, event: I) { + let _ = self.sender.send(event); + } +} + +/// Used to drop the component's event loop when the managed widget is destroyed. +enum InnerMessage { + Drop, + Message(T), +} + +/// Provides a convenience function for getting a widget out of a type. +pub trait Widget { + fn widget(&self) -> &W; +} + +/// The basis of a COSMIC widget. +/// +/// A component takes care of constructing the UI of a widget, managing an event-loop +/// which handles signals from within the widget, and supports forwarding messages to +/// the consumer of the component. +pub trait Component: Sized + 'static { + /// The arguments that are passed to the init_view method. + type InitialArgs; + + /// The message type that the component accepts as inputs. + type Input: 'static; + + /// The message type that the component provides as outputs. + type Output: 'static; + + /// The widget that was constructed by the component. + type RootWidget: Clone + AsRef; + + /// The type that's used for storing widgets created for this component. + type Widgets: 'static; + + /// Initializes the component and attaches it to the default local executor. + /// + /// Spawns an event loop on `glib::MainContext::default()`, which exists + /// for as long as the root widget remains alive. + fn register( + mut self, + args: Self::InitialArgs, + ) -> Registered { + let (mut sender, in_rx) = mpsc::unbounded_channel::(); + let (mut out_tx, output) = mpsc::unbounded_channel::(); + + let (mut widgets, widget) = self.init_view(args, &mut sender, &mut out_tx); + + let handle = Handle { + widget, + sender: sender.clone(), + }; + + let (inner_tx, mut inner_rx) = mpsc::unbounded_channel::>(); + + handle.widget.as_ref().connect_destroy({ + let sender = inner_tx.clone(); + move |_| { + let _ = sender.send(InnerMessage::Drop); + } + }); + + spawn_local(async move { + while let Some(event) = inner_rx.recv().await { + match event { + InnerMessage::Message(event) => { + self.update(&mut widgets, event, &mut sender, &mut out_tx); + } + + InnerMessage::Drop => break, + } + } + }); + + forward(in_rx, inner_tx, |event| InnerMessage::Message(event)); + + Registered { + handle, + receiver: output, + } + } + + /// Creates the initial view and root widget. + fn init_view( + &mut self, + args: Self::InitialArgs, + input: &mut Sender, + output: &mut Sender, + ) -> (Self::Widgets, Self::RootWidget); + + /// Handles input messages and enables the programmer to update the model and view. + #[allow(unused_variables)] + fn update( + &mut self, + widgets: &mut Self::Widgets, + message: Self::Input, + input: &mut Sender, + output: &mut Sender, + ) { + } +} + +/// Convenience function for `Component::register()`. +pub fn register( + model: C, + args: C::InitialArgs, +) -> Registered { + model.register(args) +} + +/// Convenience function for forwarding events from a receiver to different sender. +pub fn forward O) + 'static>( + mut receiver: Receiver, + sender: Sender, + transformer: F, +) { + spawn_local(async move { + while let Some(event) = receiver.recv().await { + if sender.send(transformer(event)).is_err() { + break; + } + } + }) +} + +/// Convenience function for launching an application. +pub fn run(func: F) { + use gtk4::prelude::*; + let app = gtk4::Application::new(None, Default::default()); + + app.connect_activate(move |app| func(app.clone())); + + app.run(); +} + +/// Convenience function for spawning tasks on the local executor +pub fn spawn_local + 'static>(func: F) { + gtk4::glib::MainContext::default().spawn_local(func); +} diff --git a/crates/component-system/src/macros.rs b/crates/component-system/src/macros.rs new file mode 100644 index 00000000..39d9aec0 --- /dev/null +++ b/crates/component-system/src/macros.rs @@ -0,0 +1,76 @@ +// Copyright 2022 System76 +// SPDX-License-Identifier: MPL-2.0 + +#[macro_export] +macro_rules! component { + ( + $(#[$attr:meta])* + $mvis:vis struct $model:ident ($args:ty) { + $( + $mpvis:vis $property:ident : $type:ty, + )* + } + + $(#[$attr2:meta])* + $wvis:vis struct $widgets_:ident($root:ty) { + $( + $wpvis:vis $widgets_property:ident : $widgets_type:ty, + )* + } + + type Input = $input:ty; + type Output = $output:ty; + + $(#[$attr3:meta])* + fn init_view( + $selfv:ident, + $argsv:ident, + $inputv:ident, + $outputv:ident + ) $init_view:block + + $(#[$attr4:meta])* + fn update( + $selfv2:ident, + $widgetsv:ident, + $messagev:ident, + $inputv2:ident, + $outputv2:ident + ) $update:block + ) => { + $(#[$attr])* + $mvis struct $model { + $($mpvis $property: $type,)* + } + + $(#[$attr2])* + $wvis struct $widgets_ { + $($wpvis $widgets_property: $widgets_type,)* + } + + impl Component for $model { + type InitialArgs = $args; + type Input = $input; + type Output = $output; + type RootWidget = $root; + type Widgets = $widgets_; + + $(#[$attr3])* + fn init_view( + &mut $selfv2, + $argsv: Self::InitialArgs, + $inputv: &mut Sender, + $outputv: &mut Sender + ) -> (Self::Widgets, Self::RootWidget) $init_view + + $(#[$attr4])* + fn update( + &mut $selfv2, + $widgetsv: &mut Self::Widgets, + $messagev: Self::Input, + $inputv2: &mut Sender, + $outputv2: &mut Sender + ) $update + } + } +}