summaryrefslogtreecommitdiff
path: root/Userland/Applications/Presenter
diff options
context:
space:
mode:
authorkleines Filmröllchen <filmroellchen@serenityos.org>2022-10-20 22:30:39 +0200
committerAndrew Kaster <andrewdkaster@gmail.com>2022-11-25 14:28:33 -0700
commitde44d6c0a6c8c44241e99010db5bb771cb967604 (patch)
treeff7580a83e7f5432184ea7a5ff04b97ecfe0f6d7 /Userland/Applications/Presenter
parent295f83e54cff1b7a8f6381e8e35805651987d160 (diff)
downloadserenity-de44d6c0a6c8c44241e99010db5bb771cb967604.zip
Applications: Add Presenter
This version can already: - load all of the defined file format except for the image type and the frame-specific stuff - navigate frames and slides (though frames are mostly stubbed out) - display text with various common settings - displays text with various fitting and scaling methods - scale and position objects correctly no matter the window size
Diffstat (limited to 'Userland/Applications/Presenter')
-rw-r--r--Userland/Applications/Presenter/CMakeLists.txt17
-rw-r--r--Userland/Applications/Presenter/Presentation.cpp157
-rw-r--r--Userland/Applications/Presenter/Presentation.h60
-rw-r--r--Userland/Applications/Presenter/PresenterWidget.cpp133
-rw-r--r--Userland/Applications/Presenter/PresenterWidget.h38
-rw-r--r--Userland/Applications/Presenter/Slide.cpp53
-rw-r--r--Userland/Applications/Presenter/Slide.h31
-rw-r--r--Userland/Applications/Presenter/SlideObject.cpp157
-rw-r--r--Userland/Applications/Presenter/SlideObject.h137
-rw-r--r--Userland/Applications/Presenter/main.cpp37
10 files changed, 820 insertions, 0 deletions
diff --git a/Userland/Applications/Presenter/CMakeLists.txt b/Userland/Applications/Presenter/CMakeLists.txt
new file mode 100644
index 0000000000..70976d4596
--- /dev/null
+++ b/Userland/Applications/Presenter/CMakeLists.txt
@@ -0,0 +1,17 @@
+serenity_component(
+ Presenter
+ RECOMMENDED
+ TARGETS Presenter
+ DEPENDS ImageDecoder FileSystemAccessServer
+)
+
+
+set(SOURCES
+ main.cpp
+ Presentation.cpp
+ PresenterWidget.cpp
+ Slide.cpp
+ SlideObject.cpp
+)
+serenity_app(Presenter ICON app-display-settings)
+target_link_libraries(Presenter PRIVATE LibImageDecoderClient LibGUI LibGfx LibFileSystemAccessClient LibCore LibMain)
diff --git a/Userland/Applications/Presenter/Presentation.cpp b/Userland/Applications/Presenter/Presentation.cpp
new file mode 100644
index 0000000000..68ba7f05af
--- /dev/null
+++ b/Userland/Applications/Presenter/Presentation.cpp
@@ -0,0 +1,157 @@
+/*
+ * Copyright (c) 2022, kleines Filmröllchen <filmroellchen@serenityos.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#include "Presentation.h"
+#include <AK/Forward.h>
+#include <AK/JsonObject.h>
+#include <LibCore/Stream.h>
+#include <LibGUI/Window.h>
+#include <LibGfx/Forward.h>
+#include <errno_codes.h>
+
+Presentation::Presentation(Gfx::IntSize normative_size, HashMap<String, String> metadata)
+ : m_normative_size(normative_size)
+ , m_metadata(std::move(metadata))
+{
+}
+
+NonnullOwnPtr<Presentation> Presentation::construct(Gfx::IntSize normative_size, HashMap<String, String> metadata)
+{
+ return NonnullOwnPtr<Presentation>(NonnullOwnPtr<Presentation>::Adopt, *new Presentation(normative_size, move(metadata)));
+}
+
+void Presentation::append_slide(Slide slide)
+{
+ m_slides.append(move(slide));
+}
+
+StringView Presentation::title() const
+{
+ return m_metadata.get("title"sv).value_or("Untitled Presentation"sv);
+}
+
+StringView Presentation::author() const
+{
+ return m_metadata.get("author"sv).value_or("Unknown Author"sv);
+}
+
+void Presentation::next_frame()
+{
+ m_current_frame_in_slide++;
+ if (m_current_frame_in_slide >= current_slide().frame_count()) {
+ m_current_frame_in_slide = 0;
+ m_current_slide = min(m_current_slide.value() + 1u, m_slides.size() - 1);
+ }
+}
+
+void Presentation::previous_frame()
+{
+ m_current_frame_in_slide.sub(1);
+ if (m_current_frame_in_slide.has_overflow()) {
+ m_current_slide.saturating_sub(1);
+ m_current_frame_in_slide = m_current_slide == 0u ? 0 : current_slide().frame_count() - 1;
+ }
+}
+
+void Presentation::go_to_first_slide()
+{
+ m_current_frame_in_slide = 0;
+ m_current_slide = 0;
+}
+
+ErrorOr<NonnullOwnPtr<Presentation>> Presentation::load_from_file(StringView file_name, NonnullRefPtr<GUI::Window> window)
+{
+ if (file_name.is_empty())
+ return ENOENT;
+ auto file = TRY(Core::Stream::File::open_file_or_standard_stream(file_name, Core::Stream::OpenMode::Read));
+ auto contents = TRY(file->read_all());
+ auto content_string = StringView { contents };
+ auto json = TRY(JsonValue::from_string(content_string));
+
+ if (!json.is_object())
+ return Error::from_string_view("Presentation must contain a global JSON object"sv);
+
+ auto const& global_object = json.as_object();
+ if (!global_object.has_number("version"sv))
+ return Error::from_string_view("Presentation file is missing a version specification"sv);
+
+ auto const version = global_object.get("version"sv).to_int(-1);
+ if (version != PRESENTATION_FORMAT_VERSION)
+ return Error::from_string_view("Presentation file has incompatible version"sv);
+
+ auto const& maybe_metadata = global_object.get("metadata"sv);
+ auto const& maybe_slides = global_object.get("slides"sv);
+
+ if (maybe_metadata.is_null() || !maybe_metadata.is_object() || maybe_slides.is_null() || !maybe_slides.is_array())
+ return Error::from_string_view("Metadata or slides in incorrect format"sv);
+
+ auto const& raw_metadata = maybe_metadata.as_object();
+ auto metadata = parse_metadata(raw_metadata);
+ auto size = TRY(parse_presentation_size(raw_metadata));
+
+ auto presentation = Presentation::construct(size, metadata);
+
+ auto const& slides = maybe_slides.as_array();
+ for (auto const& maybe_slide : slides.values()) {
+ if (!maybe_slide.is_object())
+ return Error::from_string_view("Slides must be objects"sv);
+ auto const& slide_object = maybe_slide.as_object();
+
+ auto slide = TRY(Slide::parse_slide(slide_object, window));
+ presentation->append_slide(move(slide));
+ }
+
+ return presentation;
+}
+
+HashMap<String, String> Presentation::parse_metadata(JsonObject const& metadata_object)
+{
+ HashMap<String, String> metadata;
+
+ metadata_object.for_each_member([&](auto const& key, auto const& value) {
+ metadata.set(key, value.to_string());
+ });
+
+ return metadata;
+}
+
+ErrorOr<Gfx::IntSize> Presentation::parse_presentation_size(JsonObject const& metadata_object)
+{
+ auto const& maybe_width = metadata_object.get("width"sv);
+ auto const& maybe_aspect = metadata_object.get("aspect"sv);
+
+ if (maybe_width.is_null() || !maybe_width.is_number() || maybe_aspect.is_null() || !maybe_aspect.is_string())
+ return Error::from_string_view("Width or aspect in incorrect format"sv);
+
+ // We intentionally discard floating-point data here. If you need more resolution, just use a larger width.
+ auto const width = maybe_width.to_int();
+ auto const aspect_parts = maybe_aspect.as_string().split_view(':');
+ if (aspect_parts.size() != 2)
+ return Error::from_string_view("Aspect specification must have the exact format `width:height`"sv);
+ auto aspect_width = aspect_parts[0].to_int<int>();
+ auto aspect_height = aspect_parts[1].to_int<int>();
+ if (!aspect_width.has_value() || !aspect_height.has_value() || aspect_width.value() == 0 || aspect_height.value() == 0)
+ return Error::from_string_view("Aspect width and height must be non-zero integers"sv);
+
+ auto aspect_ratio = static_cast<double>(aspect_height.value()) / static_cast<double>(aspect_width.value());
+ return Gfx::IntSize {
+ width,
+ static_cast<int>(round(static_cast<double>(width) * aspect_ratio)),
+ };
+}
+
+void Presentation::paint(Gfx::Painter& painter) const
+{
+ auto display_area = painter.clip_rect();
+ // These two should be the same, but better be safe than sorry.
+ auto width_scale = static_cast<double>(display_area.width()) / static_cast<double>(m_normative_size.width());
+ auto height_scale = static_cast<double>(display_area.height()) / static_cast<double>(m_normative_size.height());
+ auto scale = Gfx::FloatSize { static_cast<float>(width_scale), static_cast<float>(height_scale) };
+
+ // FIXME: Fill the background with a color depending on the color scheme
+ painter.clear_rect(painter.clip_rect(), Color::White);
+ current_slide().paint(painter, m_current_frame_in_slide.value(), scale);
+}
diff --git a/Userland/Applications/Presenter/Presentation.h b/Userland/Applications/Presenter/Presentation.h
new file mode 100644
index 0000000000..e793924181
--- /dev/null
+++ b/Userland/Applications/Presenter/Presentation.h
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2022, kleines Filmröllchen <filmroellchen@serenityos.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#pragma once
+
+#include "Slide.h"
+#include <AK/Forward.h>
+#include <AK/HashMap.h>
+#include <AK/NonnullOwnPtr.h>
+#include <AK/String.h>
+#include <AK/Vector.h>
+#include <LibGfx/Painter.h>
+#include <LibGfx/Size.h>
+
+static constexpr int const PRESENTATION_FORMAT_VERSION = 1;
+
+// In-memory representation of the presentation stored in a file.
+// This class also contains all the parser code for loading .presenter files.
+class Presentation {
+public:
+ ~Presentation() = default;
+
+ // We can't pass this class directly in an ErrorOr because some of the components are not properly moveable under these conditions.
+ static ErrorOr<NonnullOwnPtr<Presentation>> load_from_file(StringView file_name, NonnullRefPtr<GUI::Window> window);
+
+ StringView title() const;
+ StringView author() const;
+ Gfx::IntSize normative_size() const { return m_normative_size; }
+
+ Slide const& current_slide() const { return m_slides[m_current_slide.value()]; }
+ unsigned current_slide_number() const { return m_current_slide.value(); }
+ unsigned current_frame_in_slide_number() const { return m_current_frame_in_slide.value(); }
+
+ void next_frame();
+ void previous_frame();
+ void go_to_first_slide();
+
+ // This assumes that the caller has clipped the painter to exactly the display area.
+ void paint(Gfx::Painter& painter) const;
+
+private:
+ static HashMap<String, String> parse_metadata(JsonObject const& metadata_object);
+ static ErrorOr<Gfx::IntSize> parse_presentation_size(JsonObject const& metadata_object);
+
+ Presentation(Gfx::IntSize normative_size, HashMap<String, String> metadata);
+ static NonnullOwnPtr<Presentation> construct(Gfx::IntSize normative_size, HashMap<String, String> metadata);
+
+ void append_slide(Slide slide);
+
+ Vector<Slide> m_slides {};
+ // This is not a pixel size, but an abstract size used by the slide objects for relative positioning.
+ Gfx::IntSize m_normative_size;
+ HashMap<String, String> m_metadata;
+
+ Checked<unsigned> m_current_slide { 0 };
+ Checked<unsigned> m_current_frame_in_slide { 0 };
+};
diff --git a/Userland/Applications/Presenter/PresenterWidget.cpp b/Userland/Applications/Presenter/PresenterWidget.cpp
new file mode 100644
index 0000000000..b813a6dcce
--- /dev/null
+++ b/Userland/Applications/Presenter/PresenterWidget.cpp
@@ -0,0 +1,133 @@
+/*
+ * Copyright (c) 2022, kleines Filmröllchen <filmroellchen@serenityos.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#include "PresenterWidget.h"
+#include "LibGUI/MessageBox.h"
+#include "Presentation.h"
+#include <AK/Format.h>
+#include <LibFileSystemAccessClient/Client.h>
+#include <LibGUI/Action.h>
+#include <LibGUI/Event.h>
+#include <LibGUI/Icon.h>
+#include <LibGUI/Menu.h>
+#include <LibGUI/Painter.h>
+#include <LibGUI/Window.h>
+#include <LibGfx/Forward.h>
+#include <LibGfx/Orientation.h>
+
+PresenterWidget::PresenterWidget()
+{
+ set_min_size(100, 100);
+}
+
+ErrorOr<void> PresenterWidget::initialize_menubar()
+{
+ auto* window = this->window();
+ // Set up the menu bar.
+ auto& file_menu = window->add_menu("&File");
+ auto open_action = GUI::CommonActions::make_open_action([this](auto&) {
+ auto response = FileSystemAccessClient::Client::the().try_open_file(this->window());
+ if (response.is_error())
+ return;
+ this->set_file(response.value()->filename());
+ });
+ auto about_action = GUI::CommonActions::make_about_action("Presenter", GUI::Icon::default_icon("app-display-settings"sv));
+
+ TRY(file_menu.try_add_action(open_action));
+ TRY(file_menu.try_add_action(about_action));
+
+ auto& presentation_menu = window->add_menu("&Presentation");
+ auto next_slide_action = GUI::Action::create("&Next", { KeyCode::Key_Right }, [this](auto&) {
+ if (m_current_presentation) {
+ m_current_presentation->next_frame();
+ outln("Switched forward to slide {} frame {}", m_current_presentation->current_slide_number(), m_current_presentation->current_frame_in_slide_number());
+ update();
+ }
+ });
+ auto previous_slide_action = GUI::Action::create("&Previous", { KeyCode::Key_Left }, [this](auto&) {
+ if (m_current_presentation) {
+ m_current_presentation->previous_frame();
+ outln("Switched backward to slide {} frame {}", m_current_presentation->current_slide_number(), m_current_presentation->current_frame_in_slide_number());
+ update();
+ }
+ });
+ TRY(presentation_menu.try_add_action(next_slide_action));
+ TRY(presentation_menu.try_add_action(previous_slide_action));
+ m_next_slide_action = next_slide_action;
+ m_previous_slide_action = previous_slide_action;
+
+ TRY(presentation_menu.try_add_action(GUI::Action::create("&Full Screen", { KeyModifier::Mod_Shift, KeyCode::Key_F5 }, { KeyCode::Key_F11 }, [this](auto&) {
+ this->window()->set_fullscreen(true);
+ })));
+ TRY(presentation_menu.try_add_action(GUI::Action::create("Present From First &Slide", { KeyCode::Key_F5 }, [this](auto&) {
+ if (m_current_presentation)
+ m_current_presentation->go_to_first_slide();
+ this->window()->set_fullscreen(true);
+ })));
+
+ return {};
+}
+
+void PresenterWidget::set_file(StringView file_name)
+{
+ auto presentation = Presentation::load_from_file(file_name, *window());
+ if (presentation.is_error()) {
+ GUI::MessageBox::show_error(window(), String::formatted("The presentation \"{}\" could not be loaded.\n{}", file_name, presentation.error()));
+ } else {
+ m_current_presentation = presentation.release_value();
+ window()->set_title(String::formatted(title_template, m_current_presentation->title(), m_current_presentation->author()));
+ set_min_size(m_current_presentation->normative_size());
+ // This will apply the new minimum size.
+ update();
+ }
+}
+
+void PresenterWidget::keydown_event(GUI::KeyEvent& event)
+{
+ if (event.key() == Key_Escape && window()->is_fullscreen())
+ window()->set_fullscreen(false);
+
+ // Alternate shortcuts for forward and backward
+ switch (event.key()) {
+ case Key_Down:
+ case Key_PageDown:
+ case Key_Space:
+ case Key_N:
+ case Key_Return:
+ m_next_slide_action->activate();
+ event.accept();
+ break;
+ case Key_Up:
+ case Key_Backspace:
+ case Key_PageUp:
+ case Key_P:
+ m_previous_slide_action->activate();
+ event.accept();
+ break;
+ default:
+ break;
+ }
+}
+
+void PresenterWidget::paint_event([[maybe_unused]] GUI::PaintEvent& event)
+{
+ if (!m_current_presentation)
+ return;
+ auto normative_size = m_current_presentation->normative_size();
+ // Choose an aspect-correct size which doesn't exceed actual widget dimensions.
+ auto width_corresponding_to_height = height() * normative_size.aspect_ratio();
+ auto dimension_to_preserve = (width_corresponding_to_height > width()) ? Orientation::Horizontal : Orientation::Vertical;
+ auto display_size = size().match_aspect_ratio(normative_size.aspect_ratio(), dimension_to_preserve);
+
+ GUI::Painter painter { *this };
+ auto clip_rect = Gfx::IntRect::centered_at({ width() / 2, height() / 2 }, display_size);
+ painter.clear_clip_rect();
+ // FIXME: This currently leaves a black border when the window aspect ratio doesn't match.
+ // Figure out a way to apply the background color here as well.
+ painter.add_clip_rect(clip_rect);
+
+ m_current_presentation->paint(painter);
+}
diff --git a/Userland/Applications/Presenter/PresenterWidget.h b/Userland/Applications/Presenter/PresenterWidget.h
new file mode 100644
index 0000000000..af497a6afa
--- /dev/null
+++ b/Userland/Applications/Presenter/PresenterWidget.h
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2022, kleines Filmröllchen <filmroellchen@serenityos.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#pragma once
+
+#include "LibGUI/Action.h"
+#include "Presentation.h"
+#include <LibGUI/Event.h>
+#include <LibGUI/UIDimensions.h>
+#include <LibGUI/Widget.h>
+
+// Title, Author
+constexpr StringView const title_template = "{} ({}) — Presenter"sv;
+
+class PresenterWidget : public GUI::Widget {
+ C_OBJECT(PresenterWidget);
+
+public:
+ PresenterWidget();
+ ErrorOr<void> initialize_menubar();
+
+ virtual ~PresenterWidget() override = default;
+
+ // Errors that happen here are directly displayed to the user.
+ void set_file(StringView file_name);
+
+protected:
+ virtual void paint_event(GUI::PaintEvent&) override;
+ virtual void keydown_event(GUI::KeyEvent&) override;
+
+private:
+ OwnPtr<Presentation> m_current_presentation;
+ RefPtr<GUI::Action> m_next_slide_action;
+ RefPtr<GUI::Action> m_previous_slide_action;
+};
diff --git a/Userland/Applications/Presenter/Slide.cpp b/Userland/Applications/Presenter/Slide.cpp
new file mode 100644
index 0000000000..5a02a64536
--- /dev/null
+++ b/Userland/Applications/Presenter/Slide.cpp
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2022, kleines Filmröllchen <filmroellchen@serenityos.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#include "Slide.h"
+#include <AK/JsonObject.h>
+#include <AK/NonnullRefPtrVector.h>
+#include <LibGUI/Window.h>
+#include <LibGfx/Painter.h>
+#include <LibGfx/Size.h>
+#include <LibGfx/TextAlignment.h>
+
+Slide::Slide(NonnullRefPtrVector<SlideObject> slide_objects, String title)
+ : m_slide_objects(move(slide_objects))
+ , m_title(move(title))
+{
+}
+
+ErrorOr<Slide> Slide::parse_slide(JsonObject const& slide_json, NonnullRefPtr<GUI::Window> window)
+{
+ // FIXME: Use the text with the "title" role for a title, if there is no title given.
+ auto title = slide_json.get("title"sv).as_string_or("Untitled slide");
+
+ auto const& maybe_slide_objects = slide_json.get("objects"sv);
+ if (!maybe_slide_objects.is_array())
+ return Error::from_string_view("Slide objects must be an array"sv);
+
+ auto const& json_slide_objects = maybe_slide_objects.as_array();
+ NonnullRefPtrVector<SlideObject> slide_objects;
+ for (auto const& maybe_slide_object_json : json_slide_objects.values()) {
+ if (!maybe_slide_object_json.is_object())
+ return Error::from_string_view("Slides must be objects"sv);
+ auto const& slide_object_json = maybe_slide_object_json.as_object();
+
+ auto slide_object = TRY(SlideObject::parse_slide_object(slide_object_json, window));
+ slide_objects.append(move(slide_object));
+ }
+
+ return Slide { move(slide_objects), title };
+}
+
+void Slide::paint(Gfx::Painter& painter, unsigned int current_frame, Gfx::FloatSize display_scale) const
+{
+ for (auto const& object : m_slide_objects) {
+ if (object.is_visible_during_frame(current_frame))
+ object.paint(painter, display_scale);
+ }
+
+ // FIXME: Move this to user settings.
+ painter.draw_text(painter.clip_rect(), title(), Gfx::TextAlignment::BottomCenter);
+}
diff --git a/Userland/Applications/Presenter/Slide.h b/Userland/Applications/Presenter/Slide.h
new file mode 100644
index 0000000000..cf96aa8ae8
--- /dev/null
+++ b/Userland/Applications/Presenter/Slide.h
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2022, kleines Filmröllchen <filmroellchen@serenityos.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#pragma once
+
+#include "SlideObject.h"
+#include <AK/Forward.h>
+#include <AK/NonnullOwnPtrVector.h>
+#include <AK/String.h>
+#include <LibGfx/Forward.h>
+
+// A single slide of a presentation.
+class Slide final {
+public:
+ static ErrorOr<Slide> parse_slide(JsonObject const& slide_json, NonnullRefPtr<GUI::Window> window);
+
+ // FIXME: shouldn't be hard-coded to 1.
+ unsigned frame_count() const { return 1; }
+ StringView title() const { return m_title; }
+
+ void paint(Gfx::Painter&, unsigned current_frame, Gfx::FloatSize display_scale) const;
+
+private:
+ Slide(NonnullRefPtrVector<SlideObject> slide_objects, String title);
+
+ NonnullRefPtrVector<SlideObject> m_slide_objects;
+ String m_title;
+};
diff --git a/Userland/Applications/Presenter/SlideObject.cpp b/Userland/Applications/Presenter/SlideObject.cpp
new file mode 100644
index 0000000000..0cfb0afdb0
--- /dev/null
+++ b/Userland/Applications/Presenter/SlideObject.cpp
@@ -0,0 +1,157 @@
+/*
+ * Copyright (c) 2022, kleines Filmröllchen <filmroellchen@serenityos.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#include "SlideObject.h"
+#include <AK/JsonObject.h>
+#include <AK/RefPtr.h>
+#include <LibCore/Object.h>
+#include <LibCore/Stream.h>
+#include <LibGUI/Margins.h>
+#include <LibGfx/Font/FontDatabase.h>
+#include <LibGfx/Forward.h>
+#include <LibGfx/Orientation.h>
+#include <LibGfx/Painter.h>
+#include <LibGfx/Size.h>
+#include <LibGfx/TextWrapping.h>
+#include <LibImageDecoderClient/Client.h>
+
+ErrorOr<NonnullRefPtr<SlideObject>> SlideObject::parse_slide_object(JsonObject const& slide_object_json, NonnullRefPtr<GUI::Window> window)
+{
+ auto image_decoder_client = TRY(ImageDecoderClient::Client::try_create());
+
+ auto const& maybe_type = slide_object_json.get("type"sv);
+ if (!maybe_type.is_string())
+ return Error::from_string_view("Slide object must have a type"sv);
+
+ auto type = maybe_type.as_string();
+ RefPtr<SlideObject> object;
+ if (type == "text"sv)
+ object = TRY(try_make_ref_counted<Text>());
+ else if (type == "image"sv)
+ object = TRY(try_make_ref_counted<Image>(image_decoder_client, window));
+ else
+ return Error::from_string_view("Unsupported slide object type"sv);
+
+ slide_object_json.for_each_member([&](auto const& key, auto const& value) {
+ if (key == "type"sv)
+ return;
+ auto successful = object->set_property(key, value);
+ if (!successful)
+ dbgln("Storing {:15} = {:20} on slide object type {:8} failed, ignoring.", key, value, type);
+ });
+
+ return object.release_nonnull();
+}
+
+SlideObject::SlideObject()
+{
+ REGISTER_RECT_PROPERTY("rect", rect, set_rect);
+}
+
+// FIXME: Consider drawing a placeholder box instead.
+void SlideObject::paint(Gfx::Painter&, Gfx::FloatSize) const { }
+
+Gfx::IntRect SlideObject::transformed_bounding_box(Gfx::IntRect clip_rect, Gfx::FloatSize display_scale) const
+{
+ return m_rect.to_type<float>().scaled(display_scale.width(), display_scale.height()).to_rounded<int>().translated(clip_rect.top_left());
+}
+
+GraphicsObject::GraphicsObject()
+{
+ register_property(
+ "color", [this]() { return this->color().to_string(); },
+ [this](auto& value) {
+ auto color = Color::from_string(value.to_string());
+ if (color.has_value()) {
+ this->set_color(color.value());
+ return true;
+ }
+ return false;
+ });
+}
+
+Text::Text()
+{
+ REGISTER_STRING_PROPERTY("text", text, set_text);
+ REGISTER_FONT_WEIGHT_PROPERTY("font-weight", font_weight, set_font_weight);
+ REGISTER_TEXT_ALIGNMENT_PROPERTY("text-alignment", text_alignment, set_text_alignment);
+ REGISTER_INT_PROPERTY("font-size", font_size, set_font_size);
+ REGISTER_STRING_PROPERTY("font", font, set_font);
+}
+
+void Text::paint(Gfx::Painter& painter, Gfx::FloatSize display_scale) const
+{
+ auto scaled_bounding_box = this->transformed_bounding_box(painter.clip_rect(), display_scale);
+
+ auto scaled_font_size = display_scale.height() * static_cast<float>(m_font_size);
+ auto font = Gfx::FontDatabase::the().get(m_font, scaled_font_size, m_font_weight, 0, Gfx::Font::AllowInexactSizeMatch::Yes);
+ if (font.is_null())
+ font = Gfx::FontDatabase::default_font();
+
+ painter.draw_text(scaled_bounding_box, m_text.view(), *font, m_text_alignment, m_color, Gfx::TextElision::None, Gfx::TextWrapping::Wrap);
+}
+
+Image::Image(NonnullRefPtr<ImageDecoderClient::Client> client, NonnullRefPtr<GUI::Window> window)
+ : m_client(move(client))
+ , m_window(move(window))
+{
+ REGISTER_STRING_PROPERTY("path", image_path, set_image_path);
+ REGISTER_ENUM_PROPERTY("scaling", scaling, set_scaling, ImageScaling,
+ { ImageScaling::FitSmallest, "fit-smallest" },
+ { ImageScaling::FitLargest, "fit-largest" },
+ { ImageScaling::Stretch, "stretch" }, );
+ REGISTER_ENUM_PROPERTY("scaling-mode", scaling_mode, set_scaling_mode, Gfx::Painter::ScalingMode,
+ { Gfx::Painter::ScalingMode::SmoothPixels, "smooth-pixels" },
+ { Gfx::Painter::ScalingMode::NearestNeighbor, "nearest-neighbor" },
+ { Gfx::Painter::ScalingMode::BilinearBlend, "bilinear-blend" }, );
+}
+
+// FIXME: Should run on another thread and report errors.
+ErrorOr<void> Image::reload_image()
+{
+ auto file = TRY(Core::Stream::File::open(m_image_path, Core::Stream::OpenMode::Read));
+ auto data = TRY(file->read_all());
+ auto maybe_decoded = m_client->decode_image(data);
+ if (!maybe_decoded.has_value() || maybe_decoded.value().frames.size() < 1)
+ return Error::from_string_view("Could not decode image"sv);
+ // FIXME: Handle multi-frame images.
+ m_currently_loaded_image = maybe_decoded.value().frames.first().bitmap;
+ return {};
+}
+
+void Image::paint(Gfx::Painter& painter, Gfx::FloatSize display_scale) const
+{
+ if (!m_currently_loaded_image)
+ return;
+
+ auto transformed_bounding_box = this->transformed_bounding_box(painter.clip_rect(), display_scale);
+
+ auto image_size = m_currently_loaded_image->size();
+ auto image_aspect_ratio = image_size.aspect_ratio();
+
+ auto image_box = transformed_bounding_box;
+ if (m_scaling != ImageScaling::Stretch) {
+ auto width_corresponding_to_height = image_box.height() * image_aspect_ratio;
+ auto direction_to_preserve_for_fit = width_corresponding_to_height > image_box.width() ? Orientation::Horizontal : Orientation::Vertical;
+ // Fit largest and fit smallest are the same, except with inverted preservation conditions.
+ if (m_scaling == ImageScaling::FitLargest)
+ direction_to_preserve_for_fit = direction_to_preserve_for_fit == Orientation::Vertical ? Orientation::Horizontal : Orientation::Vertical;
+
+ image_box.set_size(image_box.size().match_aspect_ratio(image_aspect_ratio, direction_to_preserve_for_fit));
+ }
+
+ image_box = image_box.centered_within(transformed_bounding_box);
+
+ auto original_clip_rect = painter.clip_rect();
+ painter.clear_clip_rect();
+ painter.add_clip_rect(image_box);
+
+ // FIXME: Allow to set the scaling mode.
+ painter.draw_scaled_bitmap(image_box, *m_currently_loaded_image, m_currently_loaded_image->rect(), 1.0f, m_scaling_mode);
+
+ painter.clear_clip_rect();
+ painter.add_clip_rect(original_clip_rect);
+}
diff --git a/Userland/Applications/Presenter/SlideObject.h b/Userland/Applications/Presenter/SlideObject.h
new file mode 100644
index 0000000000..6c1cb74327
--- /dev/null
+++ b/Userland/Applications/Presenter/SlideObject.h
@@ -0,0 +1,137 @@
+/*
+ * Copyright (c) 2022, kleines Filmröllchen <filmroellchen@serenityos.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#pragma once
+
+#include <AK/Forward.h>
+#include <AK/NonnullOwnPtr.h>
+#include <AK/NonnullRefPtr.h>
+#include <LibCore/Object.h>
+#include <LibGUI/MessageBox.h>
+#include <LibGUI/Window.h>
+#include <LibGfx/Bitmap.h>
+#include <LibGfx/Color.h>
+#include <LibGfx/Font/Font.h>
+#include <LibGfx/Forward.h>
+#include <LibGfx/Painter.h>
+#include <LibGfx/Rect.h>
+#include <LibGfx/TextAlignment.h>
+#include <LibImageDecoderClient/Client.h>
+
+// Anything that can be on a slide.
+// For properties set in the file, we re-use the Core::Object property facility.
+class SlideObject : public Core::Object {
+ C_OBJECT_ABSTRACT(SlideObject);
+
+public:
+ virtual ~SlideObject() = default;
+
+ static ErrorOr<NonnullRefPtr<SlideObject>> parse_slide_object(JsonObject const& slide_object_json, NonnullRefPtr<GUI::Window> window);
+
+ // FIXME: Actually determine this from the file data.
+ bool is_visible_during_frame([[maybe_unused]] unsigned frame_number) const { return true; }
+
+ virtual void paint(Gfx::Painter&, Gfx::FloatSize display_scale) const;
+ ALWAYS_INLINE Gfx::IntRect transformed_bounding_box(Gfx::IntRect clip_rect, Gfx::FloatSize display_scale) const;
+
+ void set_rect(Gfx::IntRect rect) { m_rect = rect; }
+ Gfx::IntRect rect() const { return m_rect; }
+
+protected:
+ SlideObject();
+
+ Gfx::IntRect m_rect;
+};
+
+// Objects with a foreground color.
+class GraphicsObject : public SlideObject {
+ C_OBJECT_ABSTRACT(SlideObject);
+
+public:
+ virtual ~GraphicsObject() = default;
+
+ void set_color(Gfx::Color color) { m_color = color; }
+ Gfx::Color color() const { return m_color; }
+
+protected:
+ GraphicsObject();
+
+ Gfx::Color m_color;
+};
+
+class Text : public GraphicsObject {
+ C_OBJECT(SlideObject);
+
+public:
+ Text();
+ virtual ~Text() = default;
+
+ virtual void paint(Gfx::Painter&, Gfx::FloatSize display_scale) const override;
+
+ void set_font(String font) { m_font = move(font); }
+ StringView font() const { return m_font; }
+ void set_font_size(int font_size) { m_font_size = font_size; }
+ int font_size() const { return m_font_size; }
+ void set_font_weight(unsigned font_weight) { m_font_weight = font_weight; }
+ unsigned font_weight() const { return m_font_weight; }
+ void set_text_alignment(Gfx::TextAlignment text_alignment) { m_text_alignment = text_alignment; }
+ Gfx::TextAlignment text_alignment() const { return m_text_alignment; }
+ void set_text(String text) { m_text = move(text); }
+ StringView text() const { return m_text; }
+
+protected:
+ String m_text;
+ // The font family, technically speaking.
+ String m_font;
+ int m_font_size;
+ unsigned m_font_weight;
+ Gfx::TextAlignment m_text_alignment;
+};
+
+// How to scale an image object.
+enum class ImageScaling {
+ // Fit the image into the bounding box, preserving its aspect ratio.
+ FitSmallest,
+ // Match the bounding box in width and height exactly; this will change the image's aspect ratio if the aspect ratio of the bounding box is not exactly the same.
+ Stretch,
+ // Make the image fill the bounding box, preserving its aspect ratio. This means that the image will be cut off on the top and bottom or left and right, depending on which dimension is "too large".
+ FitLargest,
+};
+
+class Image : public SlideObject {
+ C_OBJECT(Image);
+
+public:
+ Image(NonnullRefPtr<ImageDecoderClient::Client>, NonnullRefPtr<GUI::Window>);
+ virtual ~Image() = default;
+
+ virtual void paint(Gfx::Painter&, Gfx::FloatSize display_scale) const override;
+
+ void set_image_path(String image_path)
+ {
+ m_image_path = move(image_path);
+ auto result = reload_image();
+ if (result.is_error())
+ GUI::MessageBox::show_error(m_window, String::formatted("Loading image {} failed: {}", m_image_path, result.error()));
+ }
+ StringView image_path() const { return m_image_path; }
+ void set_scaling(ImageScaling scaling) { m_scaling = scaling; }
+ ImageScaling scaling() const { return m_scaling; }
+ void set_scaling_mode(Gfx::Painter::ScalingMode scaling_mode) { m_scaling_mode = scaling_mode; }
+ Gfx::Painter::ScalingMode scaling_mode() const { return m_scaling_mode; }
+
+protected:
+ String m_image_path;
+ ImageScaling m_scaling { ImageScaling::FitSmallest };
+ Gfx::Painter::ScalingMode m_scaling_mode { Gfx::Painter::ScalingMode::SmoothPixels };
+
+private:
+ ErrorOr<void> reload_image();
+
+ RefPtr<Gfx::Bitmap> m_currently_loaded_image;
+ NonnullRefPtr<ImageDecoderClient::Client> m_client;
+ NonnullRefPtr<GUI::Window> m_window;
+};
diff --git a/Userland/Applications/Presenter/main.cpp b/Userland/Applications/Presenter/main.cpp
new file mode 100644
index 0000000000..17ac0f86fb
--- /dev/null
+++ b/Userland/Applications/Presenter/main.cpp
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2022, kleines Filmröllchen <filmroellchen@serenityos.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#include "PresenterWidget.h"
+#include <LibCore/ArgsParser.h>
+#include <LibCore/System.h>
+#include <LibGUI/Application.h>
+#include <LibGUI/Icon.h>
+#include <LibGUI/Window.h>
+#include <LibMain/Main.h>
+
+ErrorOr<int> serenity_main(Main::Arguments arguments)
+{
+ // rpath is required to load .presenter files, unix, sendfd and recvfd are required to talk to ImageDecoder and WindowServer.
+ TRY(Core::System::pledge("stdio rpath unix sendfd recvfd"));
+
+ String file_to_load;
+ Core::ArgsParser argument_parser;
+ argument_parser.add_positional_argument(file_to_load, "Presentation to load", "file", Core::ArgsParser::Required::No);
+ argument_parser.parse(arguments);
+
+ auto app = TRY(GUI::Application::try_create(arguments));
+ auto window = TRY(GUI::Window::try_create());
+ window->set_title("Presenter");
+ window->set_icon(GUI::Icon::default_icon("app-display-settings"sv).bitmap_for_size(16));
+ auto main_widget = TRY(window->try_set_main_widget<PresenterWidget>());
+ TRY(main_widget->initialize_menubar());
+ window->show();
+
+ if (!file_to_load.is_empty())
+ main_widget->set_file(file_to_load);
+
+ return app->exec();
+}