Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions Essa/GUI/Reactivity.hpp
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(); }
Copy link
Copy Markdown
Collaborator

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?

Copy link
Copy Markdown
Collaborator Author

@sppmacd sppmacd Jul 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an Observable class which should be used in most trivial cases (where value is stored as is in the widget). It is not tested yet though. CalculatedObservable exists 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.

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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe make asserts only for debug???

Copy link
Copy Markdown
Collaborator Author

@sppmacd sppmacd Jul 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert(t); ensures that you don't pass a null std::function which is unfortunately possible in C++

assert(!m_observing) is because otherwise Read would be observing an Observable and a function at the same, leading to unpredictable behavior (in most cases taking value from Function, but sometimes from Observer when it runs notify).

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; },
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Collaborator Author

@sppmacd sppmacd Jul 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ESSA_UNREACHABLE always crashes, as leaving an empty Read is almost certainly a programming error (forgetting to initialize it with either value, function or Observable)

Copy link
Copy Markdown
Collaborator Author

@sppmacd sppmacd Jul 26, 2023

Choose a reason for hiding this comment

The 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;
};

}
6 changes: 3 additions & 3 deletions Essa/GUI/Widgets/Button.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -45,7 +45,7 @@ void Button::click() {
EML::EMLErrorOr<void> 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 {};
}

Expand Down
10 changes: 6 additions & 4 deletions Essa/GUI/Widgets/Button.hpp
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#pragma once

#include "Essa/GUI/Widgets/ButtonBehavior.hpp"
#include <Essa/GUI/NotifyUser.hpp>
#include <Essa/GUI/Reactivity.hpp>
#include <Essa/GUI/Widgets/ButtonBehavior.hpp>
#include <Essa/GUI/Widgets/Widget.hpp>
#include <functional>
#include <optional>
Expand All @@ -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<bool> 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);
Expand Down Expand Up @@ -55,7 +57,7 @@ class Button : public Widget {

std::optional<Theme::ButtonColors> m_button_colors_override;

bool m_active { false };
Observable<bool> m_active { false };
ButtonBehavior m_behavior;
};

Expand Down
6 changes: 5 additions & 1 deletion Essa/GUI/Widgets/TextEditor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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); }

Expand Down Expand Up @@ -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());
}
Expand Down
4 changes: 4 additions & 0 deletions Essa/GUI/Widgets/TextEditor.hpp
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>
Expand All @@ -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()); }

Expand Down Expand Up @@ -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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this purpose there is a Observable<T> type. Here I need to use CalculatedObservable<T> because content is internally stored as vector of lines

};

}
4 changes: 2 additions & 2 deletions Essa/GUI/Widgets/Textfield.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ void Textfield::draw(Gfx::Painter& painter) const {
drawable.draw(painter);
},
},
m_content
m_content.get()
);
}

Expand Down Expand Up @@ -75,7 +75,7 @@ LengthVector Textfield::initial_size() const {
return LengthVector { Util::Length::Auto, Util::Length::Auto };
},
},
m_content
m_content.get()
);
}

Expand Down
14 changes: 7 additions & 7 deletions Essa/GUI/Widgets/Textfield.hpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#pragma once

#include <Essa/GUI/Graphics/RichText.hpp>
#include <Essa/GUI/Reactivity.hpp>
#include <Essa/GUI/TextAlign.hpp>
#include <Essa/GUI/Widgets/Widget.hpp>
#include <EssaUtil/Units.hpp>
Expand All @@ -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)
Expand All @@ -32,7 +32,7 @@ class Textfield : public Widget {
virtual EML::EMLErrorOr<void> load_from_eml_object(EML::Object const& object, EML::Loader& loader) override;

llgl::TTFFont const* m_font = nullptr;
std::variant<Util::UString, Gfx::RichText> m_content;
Read<std::variant<Util::UString, Gfx::RichText>> m_content;
};

}
2 changes: 1 addition & 1 deletion EssaUtil/Config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
#include <fmt/format.h>

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();
}
1 change: 1 addition & 0 deletions examples/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading