jtomschroeder / cedar

Rust framework for building visual/interactive applications
MIT License
133 stars 9 forks source link

Make update and view methods #13

Open droundy opened 7 years ago

droundy commented 7 years ago

It would seem more idiomatic to use a trait to define the relationship between model, update, view, and message. I expect that this would make it possible to explain the API more simply. Not that Program it's particularly hard, but I'm accustomed to seeing similar concepts expressed as traits in rust.

jtomschroeder commented 7 years ago

Thank you for the suggestion! Could you elaborate a bit? What type of trait are you envisioning? I'm the first to admit that the API still has some rough spots :)

droundy commented 7 years ago

It's a little hard to say what precisely the API should look like in the absence of documentation of the current API. I'm just thinking of something like:

trait Model {
    type Message: Send;
    fn view(&mut self) -> View<Message>;
    fn update(&mut self, message: &Message);
}

This should be entirely able to replace the currently circuitous definition of the Program struct (whose type constraints are only documented in its run method) and Viewable trait and the Update trait.

This change would force users to define two types (a Message and a Model) plus implement two methods (a model update and a model View). The docs can now just show this one trait, which is all a user needs to write to get an application, and which has all its requirements nicely documented. Of course, users also need to know how to create a View full of widgets, but that's not so complicated.

Ppjet6 commented 7 years ago

On 2017/07/09, David Roundy wrote:

It's a little hard to say what precisely it should look like in the absence of documentation. I'm just thinking of something like:

trait Model {
    type Message: Send;
    fn view(&mut self) -> View<Message>;
    fn update(&mut self, message: &Message);
}

This should be entirely able to replace the currently circuitous definition of the Program struct (whose type constraints are only documented in its run method) and Viewable trait and the Update trait.

This change would force users to define two types (a Message and a Model) plus implement two methods (a model update and a model View). The docs can now just show this one trait, which is all a user needs to write to get an application, and which has all its requirements nicely documented. Of course, users also need to know how to create a View full of widgets, but that's not so complicated.

-- You are receiving this because you are subscribed to this thread. Reply to this email directly or view it on GitHub: https://github.com/jtomschroeder/cedar/issues/13#issuecomment-313889844

In your example, where does the state fit? Could you try and convert the button example with this API?

I am not sure I see the benefits of this over the current architecture. The user defines the an update and view function in any case. The current Program API seems relatively well organized to me, it might need some changes reading #11 and #12, but I don't think they should go in this direction.

I see a clear loss though with the use of mutability, plus the view and update methods directly on the Model, it greatly reduces ease of testing IMHO.

-- Maxime “pep” Buquet

droundy commented 7 years ago

You already have the same mutable functions hiding mutable state (i.e FnMut) right? I guess you don't want the view function to mutate, based on your comment about mutable functions? That's an easy enough change. I was merely copying your existing Viewable trait, which allows mutation:

pub trait Viewable<M, S> {
  fn view(&mut self) -> View<M, S>;
}

Personally, I consider the efficiency gain of mutation crucial for practicality.

I did my best to change nothing in constructing the API. The primary benefit of this change would be in making the API discoverable, by placing types that clearly describe the relationships of the needed pieces all in one place. The secondary benefit would be in making the API more rustic.

extern crate cedar;

struct Model(i32); // nicer to hide the implementation than use a type synonym

enum Message {
    Increment,
    Decrement,
}

impl cedar::Program for  Model {
   fn update(&mut self, message: Message)  {
       match message {
           Message::Increment => self.0 += 1,
           Message::Decrement => self.0 -= 1,
       }
   }
   fn view(&self) -> cedar::View<Model, Message> {
      cedar::View::new()
          .button(|button| {
              button.text("+")
                  .click(|| Message::Increment)
          })
          .label(|label| label.text(Model::to_string))
          .button(|button| {
              button.text("-")
                  .click(|| Message::Decrement)
          })
  }
}

fn main() {
    Model(0).run(); // assuming here a default implementation of run, this could alternatively be a function
}

If you wanted, you could quite easily define a struct not unlike your current Application that implements this API, thus having both APIs available.

Ppjet6 commented 7 years ago

You already have the same mutable functions hiding mutable state (i.e FnMut) right? I guess you don't want the view function to mutate, based on your comment about mutable functions? That's an easy enough change.

I am not the author of the library, I am just an interested third-party and I thought I would bring in my 2 cents :)

Indeed I am not keen to mutation, and I know it is a necessary evil sometimes, but I try to avoid it while I can even at the cost of performance (when it is not absolutely needed), I feel it is easier to reason about because everything is explicit, and it keeps me sane.

Here am I mostly concerned about the API since it is what we are discussing.

For the view itself, I am not sure about the internals yet, but I would like to see something else for the API itself. Let me get back to this.

Thank you for the example.

I did my best to change nothing in constructing the API. The primary benefit of this change would be in making the API discoverable, by placing types that clearly describe the relationships of the needed pieces all in one place. The secondary benefit would be in making the API more rustic.

I am not sure I understand the need to put all the eggs in a box when function signatures are all you need to understand the relationship. I don't feel like there is a particular need to couple model/update/view even more.

Also, introducing mutation in the core part of this architecture, the API at least, scares me a bit as to how the effect system will be handled when it comes arround. I definitely don't want it to turn like another relm.

The model in Elm is the source of truth and as a user I expect certain guarantees that immutability easily gives me.

If you wanted, you could quite easily define a struct not unlike your current Application that implements this API, thus having both APIs available.

In the original example, Model can also easily be changed for a struct:

extern crate cedar;

struct Model(i32);

enum Message {
    Increment,
    Decrement,
}

fn update(model: &Model, message: Message) -> Model {
    match message {
        Message::Increment => Model( model.0 + 1 ),
        Message::Decrement => Model( model.0 - 1 ),
    }
}

fn view() -> cedar::View<Model, Message> {
    cedar::View::new()
        .button(|button| {
            button.text("+")
                .click(|| Message::Increment)
        })
        .label(|label| label.text(|model: &Model| model.0.to_string()))
        .button(|button| {
            button.text("-")
                .click(|| Message::Decrement)
        })
}

fn main() {
    cedar::Program::new(Model(0), update, view).run()
}

Now to go back to what I would like to see for the view, it's still a work in progress, but I would like to stay close to the Elm model. (this doesn't compile)

Maybe this is orthogonal to this issue and that should go in another one.

extern crate cedar;

use cedar::View;
use cedar::widgets::{Window, Button, Label};

struct Model(i32);

enum Message {
    Increment,
    Decrement,
}

fn update(model: &Model, message: Message) -> Model {
    match message {
        Message::Increment => Model( model.0 + 1 ),
        Message::Decrement => Model( model.0 - 1 ),
    }
}

fn view() -> View<Model, Message> {
    Window.new()
          .add(Button::new()
                      .text("+")
                      .click(|| Message::Increment)
         ).add(Label::new().text(|model: &Model| model.0.to_string())
         ).add(Button::new()
                      .text("-")
                      .click(|| Message::Decrement)
         )
}

fn main() {
    cedar::Program::new(Model(0), update, view).run()
}

Importing widget would actually make it more explicit as to what is used in the application, and would also allow for easy use of external crates, without having to write bindings for every single possible widgets ever.

I think at the moment cedar takes the Window widget for granted, maybe we could make this another trait, TopLevelWidget or similar.

-- Maxime “pep” Buquet

Ppjet6 commented 7 years ago

I forgot to modify the view function's signature, which is also an important thing I'd like to see :)

This still doesn't compile, and I am probably missing a lot of things, please don't hesitate to correct me.

fn view(model: Model) -> View<Model, Message> {
    Window.new()
          .add(Button::new()
                      .text("+")
                      .click(|| Message::Increment)
         ).add(Label::new().text(model.0.to_string())
         ).add(Button::new()
                      .text("-")
                      .click(|| Message::Decrement)
         )
}

-- Maxime “pep” Buquet

jtomschroeder commented 7 years ago

It seems that the heart of this issue is usability of the API, specifically: whether we need a concrete interface (trait) to define the relationship between the Model, Update, and View.

In order to compose a program, we are required to supply a model, update, and view. I prefer this simple composition to a trait, as we are not required to couple each element or impose odd semantics such as the "Model creating a view" or the "Model updating itself to create a new Model". We also want to strongly encourage a 'declarative' approach: the structure and attributes of the view are declared up-front, in terms of the model (changing over time) - no control-flow necessary. So, through Program, the relationship is explicit.


Simplicity is a top priority for cedar, which is to say that compromises will tend to err on the side of simplicity in lieu of performance or idiomaticness.

Also, I want to keep in mind that some components of cedar are not 'GUI-related', such as the functional-reactive 'core'. Can we utilize cedar without a view? Can we utilize cedar for graphics? terminal-based applications? the web??


Mutability

As it is now, many of the components are allowed mutability. Because of the lack of an effects system, I decided that being permissive about mutation would allow for easier experimentation. But, the goal is to move to a 'functional' design and remove that mutability.

Vision

I love to see excitement around cedar and people suggesting new applications and improvements!

A missing piece of these discussions is the plan going forward with cedar. As we move past the proof-of-concept phase, I need to outline the vision, in terms of design and implementation, for cedar. Documentation is lacking and things are subject to sweeping change. Hopefully, that will change soon.

Effects

Elaborated on a bit in #9.

Virtual DOM

In order to separate the platform-specific details from the View, likely something similar to a virtual DOM will be employed. The diff-patch element is performance-related, but the 'API' for defining views would allow a cedar-specific mechanism that should allow more expressiveness (through a cleaner API) and explicitness (a div or button would have particular meaning in cedar).

For example, an elm view is declared like this:

div []
    [ h2 [] [text model.topic]
    , img [src model.gifUrl] []
    , button [ onClick MorePlease ] [ text "More Please!" ]
    ]
jtomschroeder commented 7 years ago

The Update and View API has changed a bit in #18 - now pure functions (along with program). I'm thinking this fixes this issue.

awestlake87 commented 6 years ago

I'm joining this kinda late, but defining a trait for a View or a widget can definitely work alongside the current system. I don't think anything needs to change, especially since you can define the trait with minimal boilerplate, but coming from a react background, I prefer the organization that traits provide over just pure functions, so I would like to see this become a core part of the library.

I think they will work better across module boundaries because you can import the corresponding Model instead of the individual update(...) and view(...) functions. here's the basic counter example using traits, and a subcomponent Counter:

extern crate cedar;

use cedar::dom;
use cedar::dom::Builder;

trait View: Sized {
    type Message;

    fn update(self, _message: Self::Message) -> Self {
        self
    }

    fn view(&self) -> dom::Object<Self::Message>;
}

#[derive(PartialEq, Debug, Clone)]
enum Message {
    Increment,
    Decrement,
}

#[derive(Debug, Clone, Default)]
struct Counter {
    value: i32,
}

impl View for Counter {
    type Message = Message;

    fn update(self, message: Self::Message) -> Self {
        match message {
            Message::Increment => Self { value: self.value + 1 },
            Message::Decrement => Self { value: self.value - 1 },
        }
    }

    fn view(&self) -> dom::Object<Self::Message> {
        dom::label().text(self.value.to_string())
    }
}

#[derive(Debug, Clone, Default)]
struct App {
    counter: Counter,
}

impl View for App {
    type Message = Message;

    fn update(self, message: Self::Message) -> Self {
        App { counter: self.counter.update(message) }
    }

    fn view(&self) -> dom::Object<Self::Message> {
        dom::stack()
            .add(dom::button().text("+".into()).click(Message::Increment))
            .add(self.counter.view())
            .add(dom::button().text("-".into()).click(Message::Decrement))
    }
}

fn main() {
    cedar::program(
        App::default(), |app, message| app.update(message), |app| app.view()
    )
}

each component would be defined by it's model, and implement a View (or Widget) trait with an associated Message type. then every component follows a standard convention for updating and rendering, and the Model and Message types are the only things you have to worry about exporting.

jtomschroeder commented 6 years ago

If you'll bear with me, I think we should approach this traits vs. pure functions discussion as an object-oriented vs. functional design discussion. Now, of course, both will work. The question should be which jives better with what we're doing (i.e. how we're thinking about the problem).

One of the main goals of cedar (a la elm) is to provide a declarative (i.e. functional) method for programming UIs. With pure functions, it's simple: just write a function that declares (returns) a view, based on parameters.

A big element of that is separating the state from the views. A classic design would be to have a SuperButton component (subclassing Button) that manages its state and draws itself based on that state. This is an object-oriented way of thinking. In your trait-based example, you have actually added this type of behavior with the View::update method. cedar intentionally doesn't support a mechanism like that. The goal is to create a separation of concerns between state and view, not simply each view (coupled with state).

And, once we remove View::update from the View trait, we have:

trait View {
    type Message;
    fn view(&self) -> dom::Object<Self::Message>;
}

which is, more simply, a pure function.

awestlake87 commented 6 years ago

In my example, update is a function that takes a moved self. I believe this means that update is still a pure function and therefore is no different from how this library normally works. The only difference I see is that the trait has grouped these pure functions and given them a standard naming convention to follow. Im still new to functional programming so forgive me if I'm wrong, but as i understand it, i have not introduced state with this trait.

jtomschroeder commented 6 years ago

In cedar, update takes a model and a message and creates the new model. In your example, update returns a new view, which I assume contains any state that should change according to the message. An example of this is value in Counter - value is now state coupled to a view.

In cedar, a view is declared in terms of a model, not in terms of its member variables (for example).

awestlake87 commented 6 years ago

Oh ok, maybe i understand now. The problem is not because of side effects, but because the Counter part of the model is now coupled to its view function?

jtomschroeder commented 6 years ago

Right.