diff --git a/Essa/GUI/Reactivity.hpp b/Essa/GUI/Reactivity.hpp new file mode 100644 index 00000000..b5deadc3 --- /dev/null +++ b/Essa/GUI/Reactivity.hpp @@ -0,0 +1,193 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace GUI { + +template class Observer; + +namespace Reactivity { + +template class Map { +public: + using Type = std::invoke_result_t; + using ObserverFunc = std::function; + + 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 class ObservableBase { +public: + using Type = T; + + ObservableBase() = default; + ObservableBase(ObservableBase const&) = delete; + ObservableBase(ObservableBase&&) = delete; + virtual ~ObservableBase() = default; + + using ObserverFunc = std::function; + + 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 auto map(Mapper mapper) const { return Reactivity::Map { *this, std::move(mapper) }; } + +protected: + void notify(T const& state) { + for (auto const& obs : m_observers) { + obs(state); + } + } + +private: + mutable std::vector m_observers; +}; + +// Observable with constant value +template class Observable : public ObservableBase { +public: + Observable(T&& t) + : m_value(std::forward(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 class CalculatedObservable : public ObservableBase { +public: + using Calculator = std::function; + + CalculatedObservable(Calculator&& t) + : m_calculator(std::move(t)) { + assert(m_calculator); + } + + virtual T get() const override { return m_calculator(); } + using ObservableBase::notify; + +private: + Calculator m_calculator; +}; + +// Read-only wrapper for Observable. Expose this if you want to make +// some field public but read only +template class ReadOnlyObservable { +public: + using Type = T; + using ObserverFunc = std::function; + + ReadOnlyObservable(ObservableBase 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 auto map(Mapper mapper) const { + return Reactivity::Map, Mapper> { + *this, + std::move(mapper), + }; + } + +private: + ObservableBase const& m_observable; +}; + +// Value that is read from somewhere. It may contain a function that +// updates its value on every read. +template class Read { +public: + using Reader = std::function; + + 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); + 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 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::Type) {}); } + { + // Only a single source is allowed for read + assert(!m_observing); + obs.observe([this](std::remove_reference_t::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; }, + [&](Reader const& v) -> T { return v(); }, + [&](T const& t) -> T { return t; }, + }, + m_value + ); + } + +private: + std::variant m_value; + bool m_observing = false; +}; + +} diff --git a/Essa/GUI/Widgets/Button.cpp b/Essa/GUI/Widgets/Button.cpp index 694303f3..c5589d4f 100644 --- a/Essa/GUI/Widgets/Button.cpp +++ b/Essa/GUI/Widgets/Button.cpp @@ -34,9 +34,9 @@ Theme::BgFgTextColors Button::colors_for_state() const { void Button::click() { if (m_toggleable) { - m_active = !m_active; + m_active = !m_active.get(); if (on_change) - on_change(m_active); + on_change(m_active.get()); } if (on_click) on_click(); @@ -45,7 +45,7 @@ void Button::click() { EML::EMLErrorOr Button::load_from_eml_object(EML::Object const& object, EML::Loader& loader) { TRY(Widget::load_from_eml_object(object, loader)); m_toggleable = TRY(object.get_property("toggleable", EML::Value(m_toggleable)).to_bool()); - m_active = TRY(object.get_property("active", EML::Value(m_active)).to_bool()); + m_active = TRY(object.get_property("active", EML::Value(m_active.get())).to_bool()); return {}; } diff --git a/Essa/GUI/Widgets/Button.hpp b/Essa/GUI/Widgets/Button.hpp index 12a1ad41..2ccb1a8a 100644 --- a/Essa/GUI/Widgets/Button.hpp +++ b/Essa/GUI/Widgets/Button.hpp @@ -1,7 +1,8 @@ #pragma once -#include "Essa/GUI/Widgets/ButtonBehavior.hpp" #include +#include +#include #include #include #include @@ -16,10 +17,11 @@ class Button : public Widget { virtual void draw(Gfx::Painter& window) const override = 0; - bool is_active() const { return m_active; } + ReadOnlyObservable active() const { return m_active; } + bool is_active() const { return m_active.get(); } void set_active(bool active, NotifyUser notify_user = NotifyUser::Yes) { - if (m_active != active) { + if (m_active.get() != active) { m_active = active; if (notify_user == NotifyUser::Yes && on_change) on_change(active); @@ -55,7 +57,7 @@ class Button : public Widget { std::optional m_button_colors_override; - bool m_active { false }; + Observable m_active { false }; ButtonBehavior m_behavior; }; diff --git a/Essa/GUI/Widgets/TextEditor.cpp b/Essa/GUI/Widgets/TextEditor.cpp index 58076329..64995d34 100644 --- a/Essa/GUI/Widgets/TextEditor.cpp +++ b/Essa/GUI/Widgets/TextEditor.cpp @@ -20,7 +20,10 @@ namespace GUI { // FIXME: There is a bunch of narrowing conversions in this file. -TextEditor::TextEditor() { m_lines.push_back(""); } +TextEditor::TextEditor() + : m_content_observable([&]() { return content(); }) { + m_lines.push_back(""); +} int TextEditor::line_height() const { return Application::the().fixed_width_font().line_height(theme().label_font_size); } @@ -590,6 +593,7 @@ void TextEditor::update() { if (m_content_changed) { regenerate_styles(); on_content_change(); + m_content_observable.notify(content()); if (on_change) { on_change(content()); } diff --git a/Essa/GUI/Widgets/TextEditor.hpp b/Essa/GUI/Widgets/TextEditor.hpp index ab01248c..62940377 100644 --- a/Essa/GUI/Widgets/TextEditor.hpp +++ b/Essa/GUI/Widgets/TextEditor.hpp @@ -1,5 +1,6 @@ #pragma once +#include "Essa/GUI/Reactivity.hpp" #include #include #include @@ -21,6 +22,7 @@ class TextEditor : public ScrollableWidget { Util::UString const& last_line() const { return m_lines.back(); } Util::UString content() const; + ReadOnlyObservable 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 m_error_spans; bool m_content_changed = false; bool m_multiline = true; + + CalculatedObservable m_content_observable; }; } diff --git a/Essa/GUI/Widgets/Textfield.cpp b/Essa/GUI/Widgets/Textfield.cpp index 6f804f02..533fb39d 100644 --- a/Essa/GUI/Widgets/Textfield.cpp +++ b/Essa/GUI/Widgets/Textfield.cpp @@ -45,7 +45,7 @@ void Textfield::draw(Gfx::Painter& painter) const { drawable.draw(painter); }, }, - m_content + m_content.get() ); } @@ -75,7 +75,7 @@ LengthVector Textfield::initial_size() const { return LengthVector { Util::Length::Auto, Util::Length::Auto }; }, }, - m_content + m_content.get() ); } diff --git a/Essa/GUI/Widgets/Textfield.hpp b/Essa/GUI/Widgets/Textfield.hpp index ca659470..bbc04b7c 100644 --- a/Essa/GUI/Widgets/Textfield.hpp +++ b/Essa/GUI/Widgets/Textfield.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -15,12 +16,11 @@ class Textfield : public Widget { // FIXME: EML void set_font(llgl::TTFFont const& font) { m_font = &font; } - void set_content(Gfx::RichText content) { - m_content = std::move(content); - } - void set_content(Util::UString string) { - m_content = std::move(string); - } + + auto& content() { return m_content; } + void set_content(Gfx::RichText content) { m_content = std::move(content); } + void set_content(Util::UString string) { m_content = std::move(string); } + CREATE_VALUE(size_t, font_size, theme().label_font_size) CREATE_VALUE(Align, alignment, Align::CenterLeft) CREATE_VALUE(int, padding, 5) @@ -32,7 +32,7 @@ class Textfield : public Widget { virtual EML::EMLErrorOr load_from_eml_object(EML::Object const& object, EML::Loader& loader) override; llgl::TTFFont const* m_font = nullptr; - std::variant m_content; + Read> m_content; }; } diff --git a/EssaUtil/Config.cpp b/EssaUtil/Config.cpp index 6d6db3b9..b5295a14 100644 --- a/EssaUtil/Config.cpp +++ b/EssaUtil/Config.cpp @@ -3,6 +3,6 @@ #include void Util::_crash(char const* message, CppSourceLocation const& loc) { - fmt::print(stderr, "Aborting: {} ({}:{}:{})\n", message, loc.file_name(), loc.column(), loc.line()); + fmt::print(stderr, "Aborting: {} ({}:{}:{})\n", message, loc.file_name(), loc.line(), loc.column()); abort(); } diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 72ef7854..05d452e9 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -20,6 +20,7 @@ add_example("essa-gui/message-box-multiline") add_example("essa-gui/multiple-windows") add_example("essa-gui/passthrough" LIBS Xfixes X11) add_example("essa-gui/progressbars") +add_example("essa-gui/reactivity") add_example("essa-gui/rich-text") add_example("essa-gui/scrollable-container") add_example("essa-gui/scrollable-widget") diff --git a/examples/essa-gui/reactivity.cpp b/examples/essa-gui/reactivity.cpp new file mode 100644 index 00000000..d82a13ef --- /dev/null +++ b/examples/essa-gui/reactivity.cpp @@ -0,0 +1,52 @@ +#include +#include +#include +#include +#include +#include +#include + +class MainWidget : public GUI::Container { +public: + virtual void on_init() override { + set_layout(); + + // Read from function + auto* fps = add_widget(); + fps->content() = [&]() { return Util::UString::format("fps = {}", GUI::EventLoop::current().tps()); }; + fps->set_size({ 100_perc, 32_px }); + + // Read from CalculatedObservable + auto* textbox = add_widget(); + textbox->set_type(GUI::Textbox::Type::TEXT); + auto* textbox_content = add_widget(); + textbox_content->set_size({ 100_perc, 32_px }); + textbox_content->content() = textbox->content_observable(); + + // Read from Map + auto* textbox_content_mapped = add_widget(); + textbox_content_mapped->set_size({ 100_perc, 32_px }); + textbox_content_mapped->content() + = textbox->content_observable().map([](auto const& value) { return Util::UString::format("<<{}>>", value.encode()); }); + + // Read from mapped Observable + auto* checkbox = add_widget(); + checkbox->set_size({ 100_perc, 32_px }); + checkbox->set_caption("Observable"); + auto* checkbox_checked = add_widget(); + checkbox_checked->set_size({ 100_perc, 32_px }); + checkbox_checked->content() + = checkbox->active().map([](bool value) { return Util::UString::format("Checkbox checked? {}", value); }); + + // Independently assigning Observables + auto* check_btn = add_widget(); + check_btn->set_content("Check checkbox"); + check_btn->on_click = [checkbox]() { checkbox->set_active(true); }; + } +}; + +int main() { + GUI::SimpleApplication app { "Reactivity", { 1000, 1000 } }; + app.run(); + return 0; +}