-
Notifications
You must be signed in to change notification settings - Fork 0
Reactivity (aka too much webdev) #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,193 @@ | ||
| #pragma once | ||
|
|
||
| #include <EssaUtil/Config.hpp> | ||
| #include <cassert> | ||
| #include <concepts> | ||
| #include <functional> | ||
| #include <map> | ||
| #include <type_traits> | ||
| #include <variant> | ||
|
|
||
| namespace GUI { | ||
|
|
||
| template<std::copyable T> class Observer; | ||
|
|
||
| namespace Reactivity { | ||
|
|
||
| template<class Observable, class Mapper> class Map { | ||
| public: | ||
| using Type = std::invoke_result_t<Mapper, typename Observable::Type>; | ||
| using ObserverFunc = std::function<void(Type const&)>; | ||
|
|
||
| Map(Observable obs, Mapper mapper) | ||
| : m_observable(std::move(obs)) | ||
| , m_mapper(std::move(mapper)) { } | ||
|
|
||
| void observe(ObserverFunc&& observer) { | ||
| return m_observable.observe([this, observer = std::move(observer)](auto const& t) { observer(m_mapper(t)); }); | ||
| } | ||
|
|
||
| private: | ||
| Observable m_observable; | ||
| Mapper m_mapper; | ||
| }; | ||
| } | ||
|
|
||
| template<std::copyable T> class ObservableBase { | ||
| public: | ||
| using Type = T; | ||
|
|
||
| ObservableBase() = default; | ||
| ObservableBase(ObservableBase const&) = delete; | ||
| ObservableBase(ObservableBase&&) = delete; | ||
| virtual ~ObservableBase() = default; | ||
|
|
||
| using ObserverFunc = std::function<void(T const&)>; | ||
|
|
||
| void observe(ObserverFunc&& observer) const { | ||
| assert(observer); | ||
| // Inform about the initial value | ||
| observer(get()); | ||
| m_observers.push_back(std::move(observer)); | ||
| } | ||
|
|
||
| virtual T get() const = 0; | ||
|
|
||
| template<class Mapper> auto map(Mapper mapper) const { return Reactivity::Map<decltype(*this), Mapper> { *this, std::move(mapper) }; } | ||
|
|
||
| protected: | ||
| void notify(T const& state) { | ||
| for (auto const& obs : m_observers) { | ||
| obs(state); | ||
| } | ||
| } | ||
|
|
||
| private: | ||
| mutable std::vector<ObserverFunc> m_observers; | ||
| }; | ||
|
|
||
| // Observable with constant value | ||
| template<std::copyable T> class Observable : public ObservableBase<T> { | ||
| public: | ||
| Observable(T&& t) | ||
| : m_value(std::forward<T>(t)) { } | ||
|
|
||
| Observable& operator=(T const& t) { | ||
| m_value = t; | ||
| this->notify(t); | ||
| return *this; | ||
| } | ||
|
|
||
| virtual T get() const { return m_value; } | ||
|
|
||
| private: | ||
| T m_value; | ||
| }; | ||
|
|
||
| // Observable that calculates its value on the fly. They are | ||
| // typically exposed with ReadOnlyObservable. | ||
| template<std::copyable T> class CalculatedObservable : public ObservableBase<T> { | ||
| public: | ||
| using Calculator = std::function<T()>; | ||
|
|
||
| CalculatedObservable(Calculator&& t) | ||
| : m_calculator(std::move(t)) { | ||
| assert(m_calculator); | ||
| } | ||
|
|
||
| virtual T get() const override { return m_calculator(); } | ||
| using ObservableBase<T>::notify; | ||
|
|
||
| private: | ||
| Calculator m_calculator; | ||
| }; | ||
|
|
||
| // Read-only wrapper for Observable. Expose this if you want to make | ||
| // some field public but read only | ||
| template<std::copyable T> class ReadOnlyObservable { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Observables should always be read only. User is supposed to change widget parameters, not its access classes
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about CheckBox::checked which can be written programatically or switched by user, so it needs to be both Observable (to allow reading it) and writable (to allow changing state)
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. But yeah, most observables are read only :) |
||
| public: | ||
| using Type = T; | ||
| using ObserverFunc = std::function<void(T const&)>; | ||
|
|
||
| ReadOnlyObservable(ObservableBase<T> const& ob) | ||
| : m_observable(ob) { } | ||
|
|
||
| void observe(ObserverFunc&& observer) const { m_observable.observe(std::move(observer)); } | ||
| T const& get() const { return m_observable.m_value; } | ||
|
|
||
| template<class Mapper> auto map(Mapper mapper) const { | ||
| return Reactivity::Map<std::remove_reference_t<decltype(*this)>, Mapper> { | ||
| *this, | ||
| std::move(mapper), | ||
| }; | ||
| } | ||
|
|
||
| private: | ||
| ObservableBase<T> const& m_observable; | ||
| }; | ||
|
|
||
| // Value that is read from somewhere. It may contain a function that | ||
| // updates its value on every read. | ||
| template<std::copyable T> class Read { | ||
| public: | ||
| using Reader = std::function<T()>; | ||
|
|
||
| Read() = default; | ||
|
|
||
| // Construct from a function that will compute the value | ||
| // for every read. | ||
| Read& operator=(Reader&& t) { | ||
| assert(t); | ||
| // TODO: Deregister observer? | ||
| assert(!m_observing); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe make asserts only for debug???
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Note: Eventually we should deregister observers in destructor to avoid UAF |
||
| m_value = std::move(t); | ||
| return *this; | ||
| } | ||
|
|
||
| Read& operator=(T&& t) { | ||
| // TODO: Deregister observer? | ||
| assert(!m_observing); | ||
| m_value = std::move(t); | ||
| return *this; | ||
| } | ||
|
|
||
| Read(Read const&) = delete; | ||
| Read(Read&&) = delete; | ||
|
|
||
| // If possible, use lvalue ref | ||
| Read& operator=(ObservableBase<T> const& obs) { | ||
| // Only a single source is allowed for read | ||
| assert(!m_observing); | ||
| obs.observe([this](auto v) { m_value = std::move(v); }); | ||
| m_observing = true; | ||
| return *this; | ||
| } | ||
|
|
||
| // Wrappers (like ReadOnlyObservable or Map) may be lvalue | ||
| Read& operator=(auto&& obs) | ||
| requires requires() { obs.observe([](std::remove_reference_t<decltype(obs)>::Type) {}); } | ||
| { | ||
| // Only a single source is allowed for read | ||
| assert(!m_observing); | ||
| obs.observe([this](std::remove_reference_t<decltype(obs)>::Type v) { m_value = std::move(v); }); | ||
| m_observing = true; | ||
| return *this; | ||
| } | ||
|
|
||
| T get() const { | ||
| return std::visit( | ||
| Util::Overloaded { | ||
| [&](std::monostate) -> T { ESSA_UNREACHABLE; }, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How is this even possible to return template result from ESSA_UNREACHABLE in lambda expression while std::monostate is in variant?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ESSA_UNREACHABLE always crashes, as leaving an empty
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hmm, maybe we should require user to initialize Read with at least default-constructed value... ? or default constructed Read would be default constructed value (so e.g empty string). E.g. currently it crashes when commenting out line 15 in examples/essa-gui/reactivity.cpp because of non-bound Read |
||
| [&](Reader const& v) -> T { return v(); }, | ||
| [&](T const& t) -> T { return t; }, | ||
| }, | ||
| m_value | ||
| ); | ||
| } | ||
|
|
||
| private: | ||
| std::variant<std::monostate, T, Reader> m_value; | ||
| bool m_observing = false; | ||
| }; | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| #pragma once | ||
|
|
||
| #include "Essa/GUI/Reactivity.hpp" | ||
| #include <Essa/GUI/NotifyUser.hpp> | ||
| #include <Essa/GUI/TextEditing/SyntaxHighlighter.hpp> | ||
| #include <Essa/GUI/TextEditing/TextPosition.hpp> | ||
|
|
@@ -21,6 +22,7 @@ class TextEditor : public ScrollableWidget { | |
| Util::UString const& last_line() const { return m_lines.back(); } | ||
|
|
||
| Util::UString content() const; | ||
| ReadOnlyObservable<Util::UString> content_observable() const { return m_content_observable; } | ||
| void set_content(Util::UString content, NotifyUser = NotifyUser::Yes); | ||
| bool is_empty() const { return m_lines.empty() || (m_lines.size() == 1 && m_lines[0].is_empty()); } | ||
|
|
||
|
|
@@ -116,6 +118,8 @@ class TextEditor : public ScrollableWidget { | |
| std::vector<ErrorSpan> m_error_spans; | ||
| bool m_content_changed = false; | ||
| bool m_multiline = true; | ||
|
|
||
| CalculatedObservable<Util::UString> m_content_observable; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd add helper template base class, just define observable type in inheritance and call its constructor
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For this purpose there is a |
||
| }; | ||
|
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't believe callbacks in reactivity API are the best solution. We'll have to define them every time we want to bind value with state, won't we?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is an
Observableclass which should be used in most trivial cases (where value is stored as is in the widget). It is not tested yet though.CalculatedObservableexists for cases where this is not possible, as for TextEditor, which stores its content as vector of lines, so we need to calculate the resulting content every time we need it.