From ce5f6586d0cc6db2aef90e5b1b7d9befbdd2285a Mon Sep 17 00:00:00 2001 From: Timothy Werquin Date: Fri, 31 May 2024 22:41:23 +0200 Subject: [PATCH] Rocket: refactor, add unit tests Fixes #36 --- apps/rocket/App.cpp | 132 ++++-- apps/rocket/App.h | 71 ++- apps/rocket/AppWidgets.cpp | 13 + apps/rocket/AppWidgets.h | 66 +++ apps/rocket/CMakeLists.txt | 11 +- apps/rocket/Launcher.cpp | 288 ++++++++++++ apps/rocket/Launcher.h | 185 ++++++++ apps/rocket/main.cpp | 592 +------------------------ libs/rMlib/Canvas.cpp | 23 +- libs/rMlib/Device.cpp | 3 +- libs/rMlib/include/Canvas.h | 17 + libs/rMlib/include/UI/Image.h | 18 +- test/integration/assets/mines.png | 3 + test/integration/assets/startup.png | 4 +- test/integration/test.sh | 22 +- test/unit/CMakeLists.txt | 6 +- test/unit/TestRocket.cpp | 221 +++++++++ test/unit/assets/hideable-visible.png | 3 + test/unit/assets/missing-icon-app.png | 3 + test/unit/assets/rocket.png | 3 + test/unit/assets/rocket_a.png | 3 + test/unit/assets/running-current-0.png | 3 + test/unit/assets/running-current-1.png | 3 + 23 files changed, 1014 insertions(+), 679 deletions(-) create mode 100644 apps/rocket/AppWidgets.cpp create mode 100644 apps/rocket/AppWidgets.h create mode 100644 apps/rocket/Launcher.cpp create mode 100644 apps/rocket/Launcher.h create mode 100644 test/integration/assets/mines.png create mode 100644 test/unit/TestRocket.cpp create mode 100644 test/unit/assets/hideable-visible.png create mode 100644 test/unit/assets/missing-icon-app.png create mode 100644 test/unit/assets/rocket.png create mode 100644 test/unit/assets/rocket_a.png create mode 100644 test/unit/assets/running-current-0.png create mode 100644 test/unit/assets/running-current-1.png diff --git a/apps/rocket/App.cpp b/apps/rocket/App.cpp index 723fd44..745a6b4 100755 --- a/apps/rocket/App.cpp +++ b/apps/rocket/App.cpp @@ -1,11 +1,15 @@ #include "App.h" +#include + #include +#include #include #include #include +#include #include using namespace rmlib; @@ -15,6 +19,7 @@ namespace { pid_t runCommand(std::string_view cmd) { pid_t pid = fork(); + if (pid == -1) { perror("Error launching"); return -1; @@ -25,21 +30,28 @@ runCommand(std::string_view cmd) { return pid; } - std::cout << "Running: " << cmd << std::endl; setpgid(0, 0); + + std::cout << "Running: " << cmd << std::endl; execlp("/bin/sh", "/bin/sh", "-c", cmd.data(), nullptr); perror("Error running process"); return -1; } -bool -endsWith(std::string_view a, std::string_view end) { - if (a.size() < end.size()) { - return false; +void +stop(std::shared_ptr runInfo) { + const auto pid = runInfo->pid; + + if (runInfo->paused) { + kill(-pid, SIGCONT); } - return a.substr(a.size() - end.size()) == end; + int res = kill(-pid, SIGTERM); + if (res != 0) { + perror("Error killing!"); + } } + } // namespace std::optional @@ -76,17 +88,22 @@ AppDescription::read(std::string_view path, std::string_view iconDir) { } if (!result.icon.empty()) { - auto iconPath = std::string(iconDir) + '/' + result.icon + ".png"; - std::cout << "Parsing image from: " << iconPath << std::endl; - result.iconImage = ImageCanvas::load(iconPath.c_str()); - if (result.iconImage.has_value()) { - std::cout << result.iconImage->canvas.components() << std::endl; - } + result.iconPath = std::string(iconDir) + '/' + result.icon + ".png"; } return std::optional(std::move(result)); } +std::optional +AppDescription::getIcon() const { + std::cout << "Parsing image from: " << iconPath << std::endl; + auto iconImage = ImageCanvas::load(iconPath.c_str()); + if (iconImage.has_value()) { + std::cout << iconImage->canvas.components() << std::endl; + } + return iconImage; +} + std::vector readAppFiles(std::string_view directory) { const auto iconPath = std::string(directory) + "/icons"; @@ -95,11 +112,6 @@ readAppFiles(std::string_view directory) { std::vector result; for (const auto& path : paths) { - if (!endsWith(path, ".draft")) { - std::cerr << "skipping non draft file: " << path << std::endl; - continue; - } - auto appDesc = AppDescription::read(path, iconPath); if (!appDesc.has_value()) { std::cerr << "error parsing file: " << path << std::endl; @@ -112,19 +124,30 @@ readAppFiles(std::string_view directory) { return result; } +void +App::updateDescription(AppDescription desc) { + mDescription = std::move(desc); + iconImage = mDescription.getIcon(); +} + bool App::launch() { - if (runInfo.has_value()) { + if (isRunning()) { assert(false && "Shouldn't be called if the app is already running"); return false; } - auto pid = runCommand(description.command); + auto pid = runCommand(description().command); if (pid == -1) { return false; } - runInfo = AppRunInfo{ pid }; + auto runInfo = std::make_shared(); + runInfo->pid = pid; + + this->runInfo = runInfo; + + AppManager::getInstance().runInfos.emplace_back(std::move(runInfo)); return true; } @@ -133,19 +156,17 @@ void App::stop() { assert(isRunning()); - if (isPaused()) { - kill(-runInfo->pid, SIGCONT); - } - - kill(-runInfo->pid, SIGTERM); + ::stop(runInfo.lock()); } void App::pause(std::optional screen) { assert(isRunning() && !isPaused()); - kill(-runInfo->pid, SIGSTOP); - runInfo->paused = true; + auto lockedInfo = runInfo.lock(); + + kill(-lockedInfo->pid, SIGSTOP); + lockedInfo->paused = true; savedFb = std::move(screen); } @@ -160,6 +181,59 @@ App::resume(rmlib::fb::FrameBuffer* fb) { savedFb.reset(); } - kill(-runInfo->pid, SIGCONT); - runInfo->paused = false; + auto lockedInfo = runInfo.lock(); + kill(-lockedInfo->pid, SIGCONT); + lockedInfo->paused = false; +} + +AppManager& +AppManager::getInstance() { + static AppManager instance; + return instance; +} + +bool +AppManager::update() { + bool anyKilled = false; + + while (auto res = pipe.readPipe.readAll()) { + auto pid = *res; + anyKilled = true; + + runInfos.erase( + std::remove_if(runInfos.begin(), + runInfos.end(), + [pid](auto& info) { return info->pid == pid; }), + runInfos.end()); + } + + return anyKilled; +} + +void +AppManager::onSigChild(int sig) { + auto& inst = AppManager::getInstance(); + + pid_t childPid = 0; + while ((childPid = waitpid(static_cast(-1), nullptr, WNOHANG)) > 0) { + + std::cout << "Killed: " << childPid << "\n"; + + auto v = inst.pipe.writePipe.writeAll(childPid); + + if (!v.has_value()) { + std::cerr << "Error in writing pid: " << to_string(v.error()) << "\n"; + } + } +} + +AppManager::AppManager() : pipe(unistdpp::fatalOnError(unistdpp::pipe())) { + unistdpp::setNonBlocking(pipe.readPipe); + std::signal(SIGCHLD, onSigChild); +} + +AppManager::~AppManager() { + for (auto runInfo : runInfos) { + ::stop(runInfo); + } } diff --git a/apps/rocket/App.h b/apps/rocket/App.h index 52a0700..7bea275 100644 --- a/apps/rocket/App.h +++ b/apps/rocket/App.h @@ -3,17 +3,16 @@ #include #include +#include + #include #include #include #include struct AppRunInfo { - pid_t pid; + pid_t pid = -1; bool paused = false; - - // Indicates that the app should be removed when it exists - bool shouldRemove = false; }; struct AppDescription { @@ -25,7 +24,8 @@ struct AppDescription { std::string command; std::string icon; - std::optional iconImage; + std::string iconPath; + std::optional getIcon() const; static std::optional read(std::string_view path, std::string_view iconDir); @@ -34,23 +34,15 @@ struct AppDescription { std::vector readAppFiles(std::string_view directory); -struct App { - AppDescription description; - - std::optional runInfo = std::nullopt; - - std::chrono::steady_clock::time_point lastActivated; +class App { +public: + App(AppDescription desc) + : mDescription(std::move(desc)), iconImage(mDescription.getIcon()) {} - std::optional savedFb; - - // Used for UI: - rmlib::Rect launchRect; - rmlib::Rect killRect; + void updateDescription(AppDescription desc); - App(AppDescription desc) : description(std::move(desc)) {} - - bool isRunning() const { return runInfo.has_value(); } - bool isPaused() const { return isRunning() && runInfo->paused; } + bool isRunning() const { return !runInfo.expired(); } + bool isPaused() const { return isRunning() && runInfo.lock()->paused; } /// Starts a new instance of the app if it's not already running. /// \returns True if a new instance was started. @@ -60,4 +52,43 @@ struct App { void pause(std::optional screen = std::nullopt); void resume(rmlib::fb::FrameBuffer* fb = nullptr); + + const AppDescription& description() const { return mDescription; } + + const std::optional& icon() const { return iconImage; } + const std::optional& savedFB() const { return savedFb; } + void resetSavedFB() { savedFb.reset(); } + + void setRemoveOnExit() { shouldRemove = true; } + bool shouldRemoveOnExit() const { return shouldRemove; } + +private: + AppDescription mDescription; + + std::weak_ptr runInfo; + + std::optional iconImage; + std::optional savedFb; + + // Indicates that the app should be removed when it exists + bool shouldRemove = false; +}; + +class AppManager { +public: + static AppManager& getInstance(); + + bool update(); + const unistdpp::FD& getWaitFD() const { return pipe.readPipe; } + +private: + friend class App; + unistdpp::Pipe pipe; + + std::vector> runInfos; + + static void onSigChild(int sig); + + AppManager(); + ~AppManager(); }; diff --git a/apps/rocket/AppWidgets.cpp b/apps/rocket/AppWidgets.cpp new file mode 100644 index 0000000..2c90778 --- /dev/null +++ b/apps/rocket/AppWidgets.cpp @@ -0,0 +1,13 @@ +#include "AppWidgets.h" + +using namespace rmlib; + +const MemoryCanvas& +getMissingImage() { + static auto image = [] { + auto mem = MemoryCanvas(128, 128, 2); + mem.canvas.set(greyToRGB565(0xaa)); + return mem; + }(); + return image; +} diff --git a/apps/rocket/AppWidgets.h b/apps/rocket/AppWidgets.h new file mode 100644 index 0000000..670b117 --- /dev/null +++ b/apps/rocket/AppWidgets.h @@ -0,0 +1,66 @@ +#pragma once + +#include + +#include "App.h" + +const rmlib::MemoryCanvas& +getMissingImage(); + +class RunningAppWidget : public rmlib::StatelessWidget { +public: + RunningAppWidget(const App& app, + rmlib::Callback onTap, + rmlib::Callback onKill, + bool isCurrent) + : app(app) + , onTap(std::move(onTap)) + , onKill(std::move(onKill)) + , isCurrent(isCurrent) {} + + auto build(rmlib::AppContext& /*unused*/, + const rmlib::BuildContext& /*unused*/) const { + using namespace rmlib; + + const Canvas& canvas = app.savedFB().has_value() ? app.savedFB()->canvas + : getMissingImage().canvas; + + return container( + Column(GestureDetector(Sized(Image(canvas), 234, 300), + Gestures{}.onTap(onTap)), + Row(Text(app.description().name), Button("X", onKill))), + Insets::all(isCurrent ? 1 : 2), + Insets::all(isCurrent ? 2 : 1), + Insets::all(2)); + } + +private: + const App& app; + rmlib::Callback onTap; + rmlib::Callback onKill; + bool isCurrent; +}; + +class AppWidget : public rmlib::StatelessWidget { +public: + AppWidget(const App& app, rmlib::Callback onLaunch) + : app(app), onLaunch(std::move(onLaunch)) {} + + auto build(rmlib::AppContext& /*unused*/, + const rmlib::BuildContext& /*unused*/) const { + using namespace rmlib; + + const Canvas& canvas = + app.icon().has_value() ? app.icon()->canvas : getMissingImage().canvas; + return container(GestureDetector(Column(Sized(Image(canvas), 128, 128), + Text(app.description().name)), + Gestures{}.onTap(onLaunch)), + Insets::all(2), + Insets::all(1), + Insets::all(2)); + } + +private: + const App& app; + rmlib::Callback onLaunch; +}; diff --git a/apps/rocket/CMakeLists.txt b/apps/rocket/CMakeLists.txt index 192bc06..aec5b6e 100755 --- a/apps/rocket/CMakeLists.txt +++ b/apps/rocket/CMakeLists.txt @@ -1,13 +1,18 @@ project(rocket) -add_executable(${PROJECT_NAME} - main.cpp - App.cpp) +add_library(rocket_lib OBJECT App.cpp AppWidgets.cpp Launcher.cpp) +add_library(rocket::lib ALIAS rocket_lib) + +target_link_libraries(rocket_lib PUBLIC rMlib) +target_include_directories(rocket_lib PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}") + +add_executable(${PROJECT_NAME} main.cpp) target_compile_features(${PROJECT_NAME} PRIVATE cxx_std_17) target_link_libraries(${PROJECT_NAME} PRIVATE + rocket_lib rMlib) install(TARGETS ${PROJECT_NAME} diff --git a/apps/rocket/Launcher.cpp b/apps/rocket/Launcher.cpp new file mode 100644 index 0000000..e890ae9 --- /dev/null +++ b/apps/rocket/Launcher.cpp @@ -0,0 +1,288 @@ +#include "Launcher.h" + +#include + +using namespace rmlib; + +namespace { + +constexpr std::array static_app_paths = { "/opt/etc/draft", "/etc/draft" }; + +#ifndef KEY_POWER +#define KEY_POWER 116 +#endif + +} // namespace + +LauncherState +LauncherWidget::createState() { + return LauncherState{}; +} +void +LauncherState::init(rmlib::AppContext& context, + const rmlib::BuildContext& /*unused*/) { + if (auto* key = context.getInputManager().getBaseDevices().key; + key != nullptr) { + key->grab(); + } + + fbCanvas = &context.getFbCanvas(); + touchDevice = context.getInputManager().getBaseDevices().touch; + + readApps(); + + context.listenFd(AppManager::getInstance().getWaitFD().fd, [this] { + setState([](auto& self) { self.updateStoppedApps(); }); + }); + + inactivityTimer = context.addTimer( + std::chrono::minutes(1), + [this, &context] { + inactivityCountdown -= 1; + if (inactivityCountdown == 0) { + resetInactivity(); + setState([&context](auto& self) { + self.startTimer(context); + self.show(); + }); + } + }, + std::chrono::minutes(1)); +} + +bool +LauncherState::sleep() { + system("/sbin/rmmod brcmfmac"); + int res = system("echo \"mem\" > /sys/power/state"); + system("/sbin/modprobe brcmfmac"); + + if (res == 0) { + // Get the reason + auto irq = unistdpp::readFile("/sys/power/pm_wakeup_irq"); + if (!irq.has_value()) { + std::cout << "Error getting reason: " << unistdpp::to_string(irq.error()) + << std::endl; + + // If there is no irq it must be the user which pressed the button: + return true; + } + std::cout << "Reason for wake irq: " << *irq << std::endl; + return false; + } + + return false; +} +void +LauncherState::stopTimer() { + sleepTimer.disable(); + sleepCountdown = -1; +} + +void +LauncherState::startTimer(rmlib::AppContext& context, int time) { + sleepCountdown = time; + sleepTimer = context.addTimer( + std::chrono::seconds(time == 0 ? 0 : 1), + [this] { tick(); }, + std::chrono::seconds(1)); +} + +void +LauncherState::tick() const { + setState([](auto& self) { + self.sleepCountdown -= 1; + + if (self.sleepCountdown == -1) { + if (self.sleep()) { + // If the user pressed the button, stop the timer and return to the + // current app. + self.resetInactivity(); + self.sleepTimer.disable(); + self.hide(nullptr); + } else { + // Retry sleeping if failed or something else woke us. + self.sleepCountdown = retry_sleep_timeout; + } + } + }); +} + +void +LauncherState::toggle(rmlib::AppContext& context) { + if (visible) { + bool shouldStartTimer = sleepCountdown <= 0; + stopTimer(); + hide(shouldStartTimer ? &context : nullptr); + } else { + startTimer(context); + show(); + } +} + +void +LauncherState::show() { + if (visible) { + return; + } + + if (auto* current = getCurrentApp(); current != nullptr) { + current->pause(MemoryCanvas(*fbCanvas)); + } + + readApps(); + visible = true; +} + +void +LauncherState::hide(rmlib::AppContext* context) { + if (!visible) { + return; + } + + if (auto* current = getCurrentApp(); current != nullptr) { + switchApp(*current); + } else if (context != nullptr) { + startTimer(*context, 0); + } +} + +App* +LauncherState::getCurrentApp() { + auto app = std::find_if(apps.begin(), apps.end(), [this](auto& app) { + return app.description().path == currentAppPath; + }); + + if (app == apps.end()) { + return nullptr; + } + + return &*app; +} + +const App* +LauncherState::getCurrentApp() const { + // NOLINTNEXTLINE + return const_cast(this)->getCurrentApp(); +} + +void +LauncherState::switchApp(App& app) { + visible = false; + stopTimer(); + + // Pause the current app. + if (auto* currentApp = getCurrentApp(); currentApp != nullptr && + currentApp->isRunning() && + !currentApp->isPaused()) { + currentApp->pause(); + } + + // resume or launch app + if (app.isPaused()) { + if (touchDevice != nullptr) { + touchDevice->flood(); + } + app.resume(); + } else if (!app.isRunning()) { + app.resetSavedFB(); + + if (!app.launch()) { + std::cerr << "Error launching " << app.description().command << std::endl; + return; + } + } + + currentAppPath = app.description().path; +} + +void +LauncherState::updateStoppedApps() { + AppManager::getInstance().update(); + + bool shouldShow = false; + if (const auto* current = getCurrentApp(); + current != nullptr && !current->isRunning()) { + currentAppPath = ""; + shouldShow = true; + } + + apps.erase(std::remove_if(apps.begin(), + apps.end(), + [](const App& app) { + return app.shouldRemoveOnExit() && + !app.isRunning(); + }), + apps.end()); + + if (shouldShow) { + show(); + } +} + +void +LauncherState::readApps() { + const static auto app_paths = [] { + std::vector paths; + std::transform(static_app_paths.begin(), + static_app_paths.end(), + std::back_inserter(paths), + [](const auto* str) { return std::string(str); }); + + if (const auto* home = getenv("HOME"); home != nullptr) { + paths.push_back(std::string(home) + "/.config/draft"); + } + + return paths; + }(); + + std::vector appDescriptions; + for (const auto& appsPath : app_paths) { + auto decriptions = readAppFiles(appsPath); + std::move(decriptions.begin(), + decriptions.end(), + std::back_inserter(appDescriptions)); + } + + // Update known apps. + for (auto appIt = apps.begin(); appIt != apps.end();) { + + auto descIt = std::find_if(appDescriptions.begin(), + appDescriptions.end(), + [&app = *appIt](const auto& desc) { + return desc.path == app.description().path; + }); + + // Remove old apps. + if (descIt == appDescriptions.end()) { + if (!appIt->isRunning()) { + appIt = apps.erase(appIt); + continue; + } + + // Defer removing until exit. + appIt->setRemoveOnExit(); + + } else { + + // Update existing apps. + appIt->updateDescription(std::move(*descIt)); + appDescriptions.erase(descIt); + } + + ++appIt; + } + + // Any left over descriptions are new. + for (auto& desc : appDescriptions) { + apps.emplace_back(std::move(desc)); + } + + std::sort(apps.begin(), apps.end(), [](const auto& app1, const auto& app2) { + return app1.description().path < app2.description().path; + }); +} + +void +LauncherState::resetInactivity() const { + inactivityCountdown = default_inactivity_timeout; +} diff --git a/apps/rocket/Launcher.h b/apps/rocket/Launcher.h new file mode 100644 index 0000000..b6851fb --- /dev/null +++ b/apps/rocket/Launcher.h @@ -0,0 +1,185 @@ +#pragma once + +#include "App.h" +#include "AppWidgets.h" + +#include + +class LauncherState; + +class LauncherWidget : public rmlib::StatefulWidget { +public: + static LauncherState createState(); +}; + +class LauncherState : public rmlib::StateBase { + constexpr static auto default_sleep_timeout = 10; + constexpr static auto retry_sleep_timeout = 8; + constexpr static auto default_inactivity_timeout = 20; + + constexpr static rmlib::Size splash_size = { 512, 512 }; + +public: + void init(rmlib::AppContext& context, const rmlib::BuildContext& /*unused*/); + + auto header(rmlib::AppContext& context) const { + using namespace rmlib; + + const auto text = [this]() -> std::string { + switch (sleepCountdown) { + case -1: + return "Welcome"; + case 0: + return "Sleeping"; + default: + return "Sleeping in : " + std::to_string(sleepCountdown); + } + }(); + + auto button = [this, &context] { + if (sleepCountdown > 0) { + return Button( + "Stop", [this] { setState([](auto& self) { self.stopTimer(); }); }); + } + if (sleepCountdown == 0) { + // TODO: make hideable? + return Button("...", [] {}); + } + return Button("Sleep", [this, &context] { + setState([&context](auto& self) { self.startTimer(context, 0); }); + }); + }(); + + return Center(Padding( + Column(Padding(Text(text, 2 * default_text_size), Insets::all(10)), + button), + Insets::all(50))); + } + + auto runningApps() const { + using namespace rmlib; + + std::vector widgets; + for (const auto& app : apps) { + if (app.isRunning()) { + widgets.emplace_back( + app, + [this, &app] { + setState( + [&app](auto& self) { self.switchApp(*const_cast(&app)); }); + }, + [this, &app] { + setState([&app](auto& self) { + std::cout << "stopping " << app.description().name << std::endl; + const_cast(&app)->stop(); + self.stopTimer(); + }); + }, + app.description().path == currentAppPath); + } + } + return Wrap(widgets); + } + + auto appList() const { + using namespace rmlib; + + std::vector widgets; + for (const auto& app : apps) { + if (!app.isRunning()) { + widgets.emplace_back(app, [this, &app] { + setState( + [&app](auto& self) { self.switchApp(*const_cast(&app)); }); + }); + } + } + return Wrap(widgets); + } + + auto launcher(rmlib::AppContext& context) const { + using namespace rmlib; + + return Cleared(Column(header(context), runningApps(), appList())); + } + + auto build(rmlib::AppContext& context, + const rmlib::BuildContext& /*unused*/) const { + using namespace rmlib; + + const Canvas* background = nullptr; + std::optional backgroundSize = {}; + if (const auto* currentApp = getCurrentApp(); currentApp != nullptr) { + if (const auto& savedFb = currentApp->savedFB(); savedFb.has_value()) { + background = &savedFb->canvas; + } else if (const auto& icon = currentApp->icon(); icon.has_value()) { + background = &icon->canvas; + backgroundSize = splash_size; + } + } + + auto ui = [&]() -> DynamicWidget { + if (visible) { + return launcher(context); + } + + if (background == nullptr) { + return Colored(white); + } + + if (backgroundSize.has_value()) { + return Center(Sized( + Image(*background), backgroundSize->width, backgroundSize->height)); + } + return Image(*background); + }(); + + return GestureDetector( + std::move(ui), + Gestures{} + .onKeyDown([this, &context](auto keyCode) { + if (keyCode == KEY_POWER) { + setState([&context](auto& self) { self.toggle(context); }); + } + }) + .onAny([this]() { resetInactivity(); })); + } + +private: + static bool sleep(); + + void startTimer(rmlib::AppContext& context, int time = default_sleep_timeout); + void stopTimer(); + + void tick() const; + + void show(); + void hide(rmlib::AppContext* context); + void toggle(rmlib::AppContext& context); + + App* getCurrentApp(); + + const App* getCurrentApp() const; + + void switchApp(App& app); + + void updateStoppedApps(); + + void readApps(); + + void resetInactivity() const; + + std::vector apps; + std::string currentAppPath; + + std::optional backupBuffer; + + rmlib::TimerHandle sleepTimer; + rmlib::TimerHandle inactivityTimer; + + const rmlib::Canvas* fbCanvas = nullptr; + rmlib::input::InputDeviceBase* touchDevice = nullptr; + + int sleepCountdown = -1; + mutable int inactivityCountdown = default_inactivity_timeout; + bool visible = true; +}; diff --git a/apps/rocket/main.cpp b/apps/rocket/main.cpp index fd8eb3e..f2b7b9b 100644 --- a/apps/rocket/main.cpp +++ b/apps/rocket/main.cpp @@ -1,601 +1,11 @@ -#include "App.h" +#include "Launcher.h" -#include -#include -#include - -#include -#include #include -#include - using namespace rmlib; -namespace { - -constexpr std::array static_app_paths = { "/opt/etc/draft", "/etc/draft" }; - -#ifndef KEY_POWER -#define KEY_POWER 116 -#endif - -std::vector stoppedChildren; -std::function stopCallback; - -void -cleanup(int signal) { - pid_t childPid = 0; - while ((childPid = waitpid(static_cast(-1), nullptr, WNOHANG)) > 0) { - std::cout << "Exited: " << childPid << std::endl; - stoppedChildren.push_back(childPid); - } - - if (stopCallback) { - stopCallback(); - } -} - -template -class Hideable; - -template -class HideableRenderObject : public SingleChildRenderObject> { -public: - HideableRenderObject(const Hideable& widget) - : SingleChildRenderObject>( - widget, - widget.child.has_value() ? widget.child->createRenderObject() - : nullptr) {} - - Size doLayout(const Constraints& constraints) override { - if (!this->widget->child.has_value()) { - return constraints.min; - } - return this->child->layout(constraints); - } - - void update(const Hideable& newWidget) { - auto wasVisible = this->widget->child.has_value(); - this->widget = &newWidget; - if (this->widget->child.has_value()) { - if (this->child == nullptr) { - this->child = this->widget->child->createRenderObject(); - } else { - this->widget->child->update(*this->child); - } - - if (!wasVisible) { - doRefresh = true; - this->markNeedsDraw(); - // TODO: why!!?? - this->markNeedsLayout(); - } - } else if (this->widget->background != nullptr && wasVisible) { - // Don't mark the children! - RenderObject::markNeedsDraw(); - } - } - - void handleInput(const rmlib::input::Event& ev) override { - if (this->widget->child.has_value()) { - this->child->handleInput(ev); - } - } - -protected: - UpdateRegion doDraw(rmlib::Rect rect, rmlib::Canvas& canvas) override { - if (!this->widget->child.has_value()) { - if (this->widget->background != nullptr) { - const auto offset = - rect.align(this->widget->background->rect().size(), 0.5F, 0.5F) - .topLeft; - copy(canvas, - offset, - *this->widget->background, - this->widget->background->rect()); - return UpdateRegion{ rect }; - } - - return UpdateRegion{}; - } - - auto result = this->child->draw(rect, canvas); - if (doRefresh) { - doRefresh = false; - result.waveform = fb::Waveform::GC16; - result.flags = static_cast(fb::UpdateFlags::FullRefresh | - fb::UpdateFlags::Sync); - } - return result; - } - -private: - Size childSize{}; - bool doRefresh = false; -}; - -template -class Hideable : public Widget> { -private: -public: - Hideable(std::optional child, const Canvas* background = nullptr) - : child(std::move(child)), background(background) {} - - std::unique_ptr createRenderObject() const { - return std::make_unique>(*this); - } - -private: - friend class HideableRenderObject; - std::optional child; - const Canvas* background; -}; - -const auto missing_image = [] { - auto mem = MemoryCanvas(128, 128, 2); - mem.canvas.set(0xaa); - return mem; -}(); - -class RunningAppWidget : public StatelessWidget { -public: - RunningAppWidget(const App& app, - Callback onTap, - Callback onKill, - bool isCurrent) - : app(app) - , onTap(std::move(onTap)) - , onKill(std::move(onKill)) - , isCurrent(isCurrent) {} - - auto build(AppContext& /*unused*/, const BuildContext& /*unused*/) const { - const Canvas& canvas = - app.savedFb.has_value() ? app.savedFb->canvas : missing_image.canvas; - - return container( - Column(GestureDetector(Sized(Image(canvas), 234, 300), - Gestures{}.onTap(onTap)), - Row(Text(app.description.name), Button("X", onKill))), - Insets::all(isCurrent ? 1 : 2), - Insets::all(isCurrent ? 2 : 1), - Insets::all(2)); - } - -private: - const App& app; - Callback onTap; - Callback onKill; - bool isCurrent; -}; - -class AppWidget : public StatelessWidget { -public: - AppWidget(const App& app, Callback onLaunch) - : app(app), onLaunch(std::move(onLaunch)) {} - - auto build(AppContext& /*unused*/, const BuildContext& /*unused*/) const { - const Canvas& canvas = app.description.iconImage.has_value() - ? app.description.iconImage->canvas - : missing_image.canvas; - return container(GestureDetector(Column(Sized(Image(canvas), 128, 128), - Text(app.description.name)), - Gestures{}.onTap(onLaunch)), - Insets::all(2), - Insets::all(1), - Insets::all(2)); - } - -private: - const App& app; - Callback onLaunch; -}; - -class LauncherState; - -class LauncherWidget : public StatefulWidget { -public: - static LauncherState createState(); -}; - -constexpr auto default_sleep_timeout = 10; -constexpr auto retry_sleep_timeout = 8; -constexpr auto default_inactivity_timeout = 20; - -class LauncherState : public StateBase { -public: - ~LauncherState() { - stopCallback = nullptr; // TODO: stop all apps - } - - void init(AppContext& context, const BuildContext& /*unused*/) { - if (auto* key = context.getInputManager().getBaseDevices().key; - key != nullptr) { - key->grab(); - } - - fbCanvas = &context.getFbCanvas(); - touchDevice = context.getInputManager().getBaseDevices().touch; - - readApps(); // TODO: do this on turning visible - - assert(stopCallback == nullptr); - stopCallback = [this, &context] { - context.doLater( - [this] { setState([](auto& self) { self.updateStoppedApps(); }); }); - }; - - inactivityTimer = context.addTimer( - std::chrono::minutes(1), - [this, &context] { - inactivityCountdown -= 1; - if (inactivityCountdown == 0) { - resetInactivity(); - setState([&context](auto& self) { - self.startTimer(context); - self.show(); - }); - } - }, - std::chrono::minutes(1)); - } - - auto header(AppContext& context) const { - const auto text = [this]() -> std::string { - switch (sleepCountdown) { - case -1: - return "Welcome"; - case 0: - return "Sleeping"; - default: - return "Sleeping in : " + std::to_string(sleepCountdown); - } - }(); - - auto button = [this, &context] { - if (sleepCountdown > 0) { - return Button( - "Stop", [this] { setState([](auto& self) { self.stopTimer(); }); }); - } - if (sleepCountdown == 0) { - // TODO: make hideable? - return Button("...", [] {}); - } - return Button("Sleep", [this, &context] { - setState([&context](auto& self) { self.startTimer(context, 0); }); - }); - }(); - - return Center(Padding( - Column(Padding(Text(text, 2 * default_text_size), Insets::all(10)), - button), - Insets::all(50))); - } - - auto runningApps() const { - std::vector widgets; - for (const auto& app : apps) { - if (app.isRunning()) { - widgets.emplace_back( - app, - [this, &app] { - setState( - [&app](auto& self) { self.switchApp(*const_cast(&app)); }); - }, - [this, &app] { - setState([&app](auto& self) { - std::cout << "stopping " << app.description.name << std::endl; - const_cast(&app)->stop(); - self.stopTimer(); - }); - }, - app.description.path == currentAppPath); - } - } - return Wrap(widgets); - } - - auto appList() const { - std::vector widgets; - for (const auto& app : apps) { - if (!app.isRunning()) { - widgets.emplace_back(app, [this, &app] { - setState( - [&app](auto& self) { self.switchApp(*const_cast(&app)); }); - }); - } - } - return Wrap(widgets); - } - - auto launcher(AppContext& context) const { - return Cleared(Column(header(context), runningApps(), appList())); - } - - auto build(AppContext& context, const BuildContext& /*unused*/) const { - const Canvas* background = nullptr; - if (const auto* currentApp = getCurrentApp(); currentApp != nullptr) { - if (currentApp->savedFb.has_value()) { - background = ¤tApp->savedFb->canvas; - } else if (currentApp->description.iconImage.has_value()) { - background = ¤tApp->description.iconImage->canvas; - } - } - - auto ui = Hideable( - visible ? std::optional(launcher(context)) : std::nullopt, background); - - return GestureDetector( - ui, - Gestures{} - .onKeyDown([this, &context](auto keyCode) { - if (keyCode == KEY_POWER) { - setState([&context](auto& self) { self.toggle(context); }); - } - }) - .onAny([this]() { resetInactivity(); })); - } - -private: - static bool sleep() { - system("/sbin/rmmod brcmfmac"); - int res = system("echo \"mem\" > /sys/power/state"); - system("/sbin/modprobe brcmfmac"); - - if (res == 0) { - // Get the reason - auto irq = unistdpp::readFile("/sys/power/pm_wakeup_irq"); - if (!irq.has_value()) { - std::cout << "Error getting reason: " - << unistdpp::to_string(irq.error()) << std::endl; - - // If there is no irq it must be the user which pressed the button: - return true; - } - std::cout << "Reason for wake irq: " << *irq << std::endl; - return false; - } - - return false; - } - - void stopTimer() { - sleepTimer.disable(); - sleepCountdown = -1; - } - - void startTimer(AppContext& context, int time = default_sleep_timeout) { - sleepCountdown = time; - sleepTimer = context.addTimer( - std::chrono::seconds(time == 0 ? 0 : 1), - [this] { tick(); }, - std::chrono::seconds(1)); - } - - void tick() const { - setState([](auto& self) { - self.sleepCountdown -= 1; - - if (self.sleepCountdown == -1) { - if (self.sleep()) { - // If the user pressed the button, stop the timer and return to the - // current app. - self.resetInactivity(); - self.sleepTimer.disable(); - self.hide(nullptr); - } else { - // Retry sleeping if failed or something else woke us. - self.sleepCountdown = retry_sleep_timeout; - } - } - }); - } - - void toggle(AppContext& context) { - if (visible) { - bool shouldStartTimer = sleepCountdown <= 0; - stopTimer(); - hide(shouldStartTimer ? &context : nullptr); - } else { - startTimer(context); - show(); - } - } - - void show() { - if (visible) { - return; - } - - if (auto* current = getCurrentApp(); current != nullptr) { - current->pause(MemoryCanvas(*fbCanvas)); - } - - readApps(); - visible = true; - } - - void hide(AppContext* context) { - if (!visible) { - return; - } - - if (auto* current = getCurrentApp(); current != nullptr) { - switchApp(*current); - } else if (context != nullptr) { - startTimer(*context, 0); - } - } - - App* getCurrentApp() { - auto app = std::find_if(apps.begin(), apps.end(), [this](auto& app) { - return app.description.path == currentAppPath; - }); - - if (app == apps.end()) { - return nullptr; - } - - return &*app; - } - - const App* getCurrentApp() const { - return const_cast(this)->getCurrentApp(); - } - - void switchApp(App& app) { - app.lastActivated = std::chrono::steady_clock::now(); - - visible = false; - stopTimer(); - - // Pause the current app. - if (auto* currentApp = getCurrentApp(); currentApp != nullptr && - currentApp->isRunning() && - !currentApp->isPaused()) { - currentApp->pause(); - } - - // resume or launch app - if (app.isPaused()) { - if (touchDevice != nullptr) { - touchDevice->flood(); - } - app.resume(); - } else if (!app.isRunning()) { - app.savedFb = std::nullopt; - - if (!app.launch()) { - std::cerr << "Error launching " << app.description.command << std::endl; - return; - } - } - - currentAppPath = app.description.path; - } - - void updateStoppedApps() { - std::vector currentStoppedApps; - std::swap(currentStoppedApps, stoppedChildren); - - for (const auto pid : currentStoppedApps) { - auto app = std::find_if(apps.begin(), apps.end(), [pid](auto& app) { - return app.isRunning() && app.runInfo->pid == pid; - }); - - if (app == apps.end()) { - continue; - } - - const auto isCurrent = app->description.path == currentAppPath; - - if (app->runInfo->shouldRemove) { - apps.erase(app); - } else { - app->runInfo = std::nullopt; - } - - if (isCurrent) { - currentAppPath = ""; - show(); - } - } - } - - void readApps() { - const static auto app_paths = [] { - std::vector paths; - std::transform(static_app_paths.begin(), - static_app_paths.end(), - std::back_inserter(paths), - [](const auto* str) { return std::string(str); }); - - if (const auto* home = getenv("HOME"); home != nullptr) { - paths.push_back(std::string(home) + "/.config/draft"); - } - - return paths; - }(); - - std::vector appDescriptions; - for (const auto& appsPath : app_paths) { - auto decriptions = readAppFiles(appsPath); - std::move(decriptions.begin(), - decriptions.end(), - std::back_inserter(appDescriptions)); - } - - // Update known apps. - for (auto appIt = apps.begin(); appIt != apps.end();) { - - auto descIt = std::find_if(appDescriptions.begin(), - appDescriptions.end(), - [&app = *appIt](const auto& desc) { - return desc.path == app.description.path; - }); - - // Remove old apps. - if (descIt == appDescriptions.end()) { - if (!appIt->isRunning()) { - appIt = apps.erase(appIt); - continue; - } - - // Defer removing until exit. - appIt->runInfo->shouldRemove = true; - - } else { - - // Update existing apps. - appIt->description = std::move(*descIt); - appDescriptions.erase(descIt); - } - - ++appIt; - } - - // Any left over descriptions are new. - for (auto& desc : appDescriptions) { - apps.emplace_back(std::move(desc)); - } - - std::sort(apps.begin(), apps.end(), [](const auto& app1, const auto& app2) { - return app1.description.path < app2.description.path; - }); - } - - void resetInactivity() const { - inactivityCountdown = default_inactivity_timeout; - } - - std::vector apps; - std::string currentAppPath; - - std::optional backupBuffer; - - TimerHandle sleepTimer; - TimerHandle inactivityTimer; - - const Canvas* fbCanvas = nullptr; - input::InputDeviceBase* touchDevice = nullptr; - - int sleepCountdown = -1; - mutable int inactivityCountdown = default_inactivity_timeout; - bool visible = true; -}; - -LauncherState -LauncherWidget::createState() { - return LauncherState{}; -} - -} // namespace - int main(int argc, char* argv[]) { - - std::signal(SIGCHLD, cleanup); - unistdpp::fatalOnError(runApp(LauncherWidget())); auto fb = fb::FrameBuffer::open(); diff --git a/libs/rMlib/Canvas.cpp b/libs/rMlib/Canvas.cpp index a429444..673d61d 100644 --- a/libs/rMlib/Canvas.cpp +++ b/libs/rMlib/Canvas.cpp @@ -24,23 +24,6 @@ blend(uint8_t factor, uint8_t fg, uint8_t bg) { return uint8_t(val); } -constexpr uint16_t -toRGB565(uint8_t grey) { - // NOLINTNEXTLINE - return (grey >> 3) | ((grey >> 2) << 5) | ((grey >> 3) << 11); -} - -constexpr uint8_t -fromRGB565(uint16_t rgb) { - // uint8_t r = (rgb & 0x1f) << 3; - // NOLINTNEXTLINE - uint8_t g = ((rgb >> 5) & 0x3f) << 2; - // uint8_t b = ((rgb >> 11) & 0x1f) << 3; - - // Only use g for now, as it has the most bit depth. - return g; -}; - #ifdef EMULATE #include "noto-sans-mono.h" #else @@ -190,7 +173,7 @@ Canvas::drawText(std::string_view text, // int pixel = bg8 + (t * (fg8 - bg8)) / 0xff; auto pixel = blend(t, fg8, bg8); // auto pixel = (0xff - textBuffer[y * w + x]); - uint16_t pixel565 = toRGB565(pixel); + uint16_t pixel565 = greyToRGB565(pixel); // (pixel >> 3) | ((pixel >> 2) << 5) | ((pixel >> 3) << 11); auto memY = location.y + static_cast(baseLine) + y0 + y /*- 1*/; @@ -269,7 +252,7 @@ greyAlphaToRGB565(uint8_t background, uint16_t pixel) { auto blended = blend(alpha, grey, background); - return toRGB565(blended); + return greyToRGB565(blended); } std::optional @@ -344,7 +327,7 @@ writeImage(const char* path, const Canvas& canvas) { MemoryCanvas test(canvas.width(), canvas.height(), 1); canvas.forEach([&](auto x, auto y, auto pixel) { - test.canvas.setPixel({ x, y }, fromRGB565(pixel)); + test.canvas.setPixel({ x, y }, greyFromRGB565(pixel)); }); if (stbi_write_png(path, diff --git a/libs/rMlib/Device.cpp b/libs/rMlib/Device.cpp index abb84d4..c1bd7ba 100644 --- a/libs/rMlib/Device.cpp +++ b/libs/rMlib/Device.cpp @@ -2,9 +2,9 @@ #include +#include #include #include -#include #include namespace rmlib::device { @@ -119,6 +119,7 @@ listDirectory(std::string_view path, bool onlyFiles) { closedir(dir); + std::sort(result.begin(), result.end()); return result; } diff --git a/libs/rMlib/include/Canvas.h b/libs/rMlib/include/Canvas.h index d3566d8..9593f35 100755 --- a/libs/rMlib/include/Canvas.h +++ b/libs/rMlib/include/Canvas.h @@ -19,6 +19,23 @@ constexpr auto default_text_size = 48; constexpr auto white = 0xFFFF; constexpr auto black = 0x0; +constexpr uint16_t +greyToRGB565(uint8_t grey) { + // NOLINTNEXTLINE + return (grey >> 3) | ((grey >> 2) << 5) | ((grey >> 3) << 11); +} + +constexpr uint8_t +greyFromRGB565(uint16_t rgb) { + // uint8_t r = (rgb & 0x1f) << 3; + // NOLINTNEXTLINE + uint8_t g = ((rgb >> 5) & 0x3f) << 2; + // uint8_t b = ((rgb >> 11) & 0x1f) << 3; + + // Only use g for now, as it has the most bit depth. + return g; +} + // Returns a glyph for the given codepoint. bool getGlyph(uint32_t code, uint8_t* bitmap, int height, int* width); diff --git a/libs/rMlib/include/UI/Image.h b/libs/rMlib/include/UI/Image.h index 87b8287..789ff85 100644 --- a/libs/rMlib/include/UI/Image.h +++ b/libs/rMlib/include/UI/Image.h @@ -93,10 +93,17 @@ class ImageRenderObject : public LeafRenderObject { } UpdateRegion doDraw(rmlib::Rect rect, rmlib::Canvas& canvas) override { + const auto& image = widget->canvas; + + if (image.rect().size() == rect.size()) { + copy(canvas, rect.topLeft, image, image.rect()); + return UpdateRegion{ rect }; + } + auto rectW = float(rect.width()); auto rectH = float(rect.height()); - auto canvasW = float(widget->canvas.width()); - auto canvasH = float(widget->canvas.height()); + auto canvasW = float(image.width()); + auto canvasH = float(image.height()); float scaleX = rectW / canvasW; float scaleY = rectH / canvasH; int offsetX = 0; @@ -116,16 +123,13 @@ class ImageRenderObject : public LeafRenderObject { [&](int x, int y, int old) { auto subY = int(float(y - rect.topLeft.y - offsetY) / scaleY); auto subX = int(float(x - rect.topLeft.x - offsetX) / scaleX); - if (!widget->canvas.rect().contains(Point{ subX, subY })) { + if (!image.rect().contains(Point{ subX, subY })) { return old; } - auto pixel = widget->canvas.getPixel(subX, subY); - // auto* pixel = widget->canvas.getPtr(subX, subY); - return pixel; + return image.getPixel(subX, subY); }, rect); - return UpdateRegion{ rect }; } }; diff --git a/test/integration/assets/mines.png b/test/integration/assets/mines.png new file mode 100644 index 0000000..19cb520 --- /dev/null +++ b/test/integration/assets/mines.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a99882f4c696564743cfb091a170b2f52692e96cfd29df5a640b891171b775aa +size 31871 diff --git a/test/integration/assets/startup.png b/test/integration/assets/startup.png index 744039a..6455d8d 100644 --- a/test/integration/assets/startup.png +++ b/test/integration/assets/startup.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8cd9307c20a6b0e50e1989d298031a933a0edcbf9f4e244e706e910bd6d0a955 -size 39696 +oid sha256:7b44c2984374b4cdfcb0518712f76b781d70c9706465cdf9ecdb81fd238d3539 +size 41092 diff --git a/test/integration/test.sh b/test/integration/test.sh index aeeec95..aa448f1 100755 --- a/test/integration/test.sh +++ b/test/integration/test.sh @@ -65,7 +65,8 @@ scp -P 2222 "$IPKS_PATH"/*.ipk root@localhost: do_ssh systemctl restart systemd-timesyncd do_ssh opkg update -do_ssh opkg install ./*.ipk calculator || true # TODO: xochitl doesn't configure for 3.5+ +# TODO: xochitl doesn't configure for 3.5+ +do_ssh opkg install ./*.ipk calculator mines || true # Start rocket, which should trigger the rm2fb socket and start the service. do_ssh systemctl start rocket @@ -75,7 +76,7 @@ sleep 2 check_screenshot "startup.png" # tilem -tap_at 666 1050 +tap_at 730 1050 sleep 2 check_screenshot "tilem.png" tap_at 840 962 @@ -83,7 +84,7 @@ sleep 2 check_screenshot "startup.png" # Yaft -tap_at 986 1042 +tap_at 1058 1042 sleep 3 check_screenshot "yaft.png" tap_at 76 1832 @@ -93,7 +94,7 @@ sleep 2 check_screenshot "startup.png" # Calculator -tap_at 484 1054 +tap_at 400 1054 sleep 3 check_screenshot "calculator.png" tap_at 826 1440 @@ -108,6 +109,19 @@ tap_at 824 1124 # Kill calculator sleep 1 check_screenshot "startup.png" +# mines +tap_at 600 1054 +sleep 2 +check_screenshot "mines.png" + +press_power +sleep 1 +tap_at 702 718 # Stop sleeping +sleep 2 +tap_at 764 1124 # Kill mines +sleep 1 +check_screenshot "startup.png" + # Xochitl tap_at 832 1086 sleep 60 diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt index 2d4b7f3..497511f 100644 --- a/test/unit/CMakeLists.txt +++ b/test/unit/CMakeLists.txt @@ -4,7 +4,8 @@ add_executable(${PROJECT_NAME} TestUnistdpp.cpp TestRMLib.cpp TestTilem.cpp - TestYaft.cpp) + TestYaft.cpp + TestRocket.cpp) target_compile_definitions(${PROJECT_NAME} PRIVATE ASSETS_PATH="${CMAKE_CURRENT_SOURCE_DIR}/assets") @@ -15,6 +16,7 @@ target_link_libraries(${PROJECT_NAME} unistdpp rMlib tilem::lib - Yaft::app_lib) + Yaft::app_lib + rocket::lib) add_test(NAME "unit" COMMAND ${PROJECT_NAME}) diff --git a/test/unit/TestRocket.cpp b/test/unit/TestRocket.cpp new file mode 100644 index 0000000..2b60cba --- /dev/null +++ b/test/unit/TestRocket.cpp @@ -0,0 +1,221 @@ +#include +#include + +#include "TempFiles.h" +#include "rMLibTestHelper.h" + +#include "App.h" +#include "AppWidgets.h" +#include "Launcher.h" + +#include "unistdpp/file.h" + +#include + +using namespace rmlib; + +void +writeFile(const std::filesystem::path& path, std::string_view txt) { + std::ofstream ofs(path.string()); + REQUIRE(ofs.is_open()); + + ofs << txt; +} + +TEST_CASE("App::read", "[rocket]") { + TemporaryDirectory tmp; + + SECTION("Basic reading") { + const auto path = tmp.dir / "basic.draft"; + writeFile(path, R"( +name=xochitl +desc=Read documents and take notes +call=/usr/bin/xochitl +term=: +imgFile=xochitl + )"); + + auto app = AppDescription::read(path.c_str(), (tmp.dir / "icons").c_str()); + REQUIRE(app.has_value()); + + CHECK(app->name == "xochitl"); + CHECK(app->description == "Read documents and take notes"); + CHECK(app->command == "/usr/bin/xochitl"); + CHECK(app->icon == "xochitl"); + CHECK(app->iconPath == tmp.dir / "icons" / "xochitl.png"); + } + + SECTION("No command") { + const auto path = tmp.dir / "basic.draft"; + writeFile(path, R"( +name=xochitl +desc=Read documents and take notes +term=: +imgFile=xochitl + )"); + + auto app = AppDescription::read(path.c_str(), (tmp.dir / "icons").c_str()); + REQUIRE_FALSE(app.has_value()); + } + + SECTION("No name") { + const auto path = tmp.dir / "basic.draft"; + writeFile(path, R"( +desc=Read documents and take notes +call=/usr/bin/xochitl +term=: +imgFile=xochitl + )"); + + auto app = AppDescription::read(path.c_str(), (tmp.dir / "icons").c_str()); + REQUIRE_FALSE(app.has_value()); + } + + SECTION("Only name and command") { + const auto path = tmp.dir / "basic.draft"; + writeFile(path, R"( +name=xochitl +call=/usr/bin/xochitl + )"); + + auto app = AppDescription::read(path.c_str(), (tmp.dir / "icons").c_str()); + REQUIRE(app.has_value()); + CHECK(app->name == "xochitl"); + CHECK(app->command == "/usr/bin/xochitl"); + } +} + +TEST_CASE("readAppFiles", "[rocket]") { + TemporaryDirectory tmp; + + writeFile(tmp.dir / "a.draft", R"( +name=a +desc=A a +call=/usr/bin/a +term=: +imgFile=a + )"); + writeFile(tmp.dir / "b.draft", R"( +name=b +call=/usr/bin/b + )"); + writeFile(tmp.dir / "c", R"( +name=c +call=/usr/bin/c --foo +imgFile=c + )"); + + auto files = readAppFiles(tmp.dir.c_str()); + + REQUIRE(files.size() == 3); + + const auto& a = files[0]; + CHECK(a.name == "a"); + CHECK(a.description == "A a"); + CHECK(a.command == "/usr/bin/a"); + CHECK(a.icon == "a"); + CHECK(a.iconPath == tmp.dir / "icons" / "a.png"); + + const auto& b = files[1]; + CHECK(b.name == "b"); + CHECK(b.command == "/usr/bin/b"); + + const auto& c = files[2]; + CHECK(c.name == "c"); + CHECK(c.command == "/usr/bin/c --foo"); + CHECK(c.iconPath == tmp.dir / "icons" / "c.png"); +} + +TEST_CASE("App", "[rocket]") { + App app(AppDescription{ .name = "yes", .command = "sleep 5000" }); + + REQUIRE(app.launch()); + usleep(500); + + REQUIRE(app.isRunning()); + REQUIRE_FALSE(app.isPaused()); + + app.stop(); + + while (!AppManager::getInstance().update()) { + usleep(500); + } + + REQUIRE_FALSE(app.isRunning()); +} + +TEST_CASE("AppWidget", "[rocket]") { + auto ctx = TestContext::make(); + + App app(AppDescription{ .name = "foo", .command = "/usr/bin/ls" }); + + SECTION("AppWidget") { + int clicked = 0; + ctx.pumpWidget(Center(AppWidget(app, [&] { clicked++; }))); + + auto appWidget = ctx.findByType(); + REQUIRE_THAT(appWidget, ctx.matchesGolden("missing-icon-app.png")); + + ctx.tap(appWidget); + REQUIRE(clicked == 1); + } + + SECTION("RunningAppWidget") { + int tapped = 0; + int killed = 0; + + bool current = GENERATE(true, false); + + ctx.pumpWidget(Center(RunningAppWidget( + app, [&] { tapped++; }, [&] { killed++; }, current))); + + auto appWidget = ctx.findByType(); + REQUIRE_THAT( + appWidget, + ctx.matchesGolden("running-current-" + std::to_string(current) + ".png")); + + ctx.tap(appWidget); + REQUIRE(tapped == 1); + + auto closeTxt = ctx.findByText("X"); + ctx.tap(closeTxt); + REQUIRE(killed == 1); + } +} + +TEST_CASE("Launcher", "[rocket]") { + TemporaryDirectory tmp; + + const auto configPath = tmp.dir / ".config" / "draft"; + REQUIRE_NOTHROW(std::filesystem::create_directories(configPath)); + setenv("HOME", tmp.dir.c_str(), true); + + writeFile(configPath / "a.draft", R"( +name=a +desc=A a +call=sleep 1 +term=: +imgFile=a + )"); + writeFile(configPath / "b.draft", R"( +name=b +call=yes + )"); + + auto ctx = TestContext::make(); + ctx.pumpWidget(Center(LauncherWidget())); + auto launcher = ctx.findByType(); + + REQUIRE_THAT(launcher, ctx.matchesGolden("rocket.png")); + + auto labelA = ctx.findByText("a"); + ctx.tap(labelA); + ctx.pump(); + + REQUIRE_THAT(launcher, ctx.matchesGolden("rocket_a.png")); + + sleep(2); + ctx.pump(); + + REQUIRE_THAT(launcher, ctx.matchesGolden("rocket.png")); +} diff --git a/test/unit/assets/hideable-visible.png b/test/unit/assets/hideable-visible.png new file mode 100644 index 0000000..b9f32a6 --- /dev/null +++ b/test/unit/assets/hideable-visible.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c16f25d0fa668a572229e61534aa2f50df1dd3e22ae72d462b80ca2559dabda7 +size 533 diff --git a/test/unit/assets/missing-icon-app.png b/test/unit/assets/missing-icon-app.png new file mode 100644 index 0000000..4b0a751 --- /dev/null +++ b/test/unit/assets/missing-icon-app.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dbb8ef3f23bba389dd6962f0cdb362766761cabf6a90954c2b272bed69317658 +size 1085 diff --git a/test/unit/assets/rocket.png b/test/unit/assets/rocket.png new file mode 100644 index 0000000..4791fc1 --- /dev/null +++ b/test/unit/assets/rocket.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:638a929180415996084b2b5f43d045d7f61ec09bbd04acb8613b9e2368aa41a5 +size 11767 diff --git a/test/unit/assets/rocket_a.png b/test/unit/assets/rocket_a.png new file mode 100644 index 0000000..ed66843 --- /dev/null +++ b/test/unit/assets/rocket_a.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f6242242d8386a93f0492605bb28778594bded8c53a8ba35410236ace1b8dd4 +size 27866 diff --git a/test/unit/assets/running-current-0.png b/test/unit/assets/running-current-0.png new file mode 100644 index 0000000..aa3fe2c --- /dev/null +++ b/test/unit/assets/running-current-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc99b38af248343ecc661915a234149e7ebf930e770be10938de79605521ea6d +size 2190 diff --git a/test/unit/assets/running-current-1.png b/test/unit/assets/running-current-1.png new file mode 100644 index 0000000..abe2bc4 --- /dev/null +++ b/test/unit/assets/running-current-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d9f948a1ca9e76cf9f0c152434ee8f52ebfd85a9a4d422c26d197f5da6679e46 +size 2197