Skip to content
Merged
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
29 changes: 29 additions & 0 deletions examples/pastebin/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# SPDX-License-Identifier: Apache-2.0
#
# pastebin — rung 1 of the application ladder (examples/pastebin/README.md).
# All target wiring lives in morph_add_rung() (cmake/morph_add_rung.cmake);
# this file only pulls in pastebin-specific dependencies morph_add_rung()
# itself doesn't know about, then calls it.

cmake_minimum_required(VERSION 3.25)

morph_add_rung(NAME pastebin)

# ── The WASM client's server url ────────────────────────────────────────────
# A page served from a static bundle has no argv to read a --server flag from,
# so the url the browser client connects to is a build-time constant. Same
# mechanism and same shape as the rung-0 spike's own
# MORPH_LADDER_WASM_SPIKE_SERVER_URL (examples/common/wasm_spike/CMakeLists.txt),
# under a per-rung name so several rungs' WASM clients can point at their own
# servers in one Emscripten configure. Guarded on the target rather than on
# EMSCRIPTEN directly: morph_add_rung() creates it only under Emscripten, and
# only when its prerequisites are met (it announces every skip).
if(TARGET ladder_pastebin_gui_wasm)
if(NOT DEFINED MORPH_LADDER_PASTEBIN_WASM_SERVER_URL)
set(MORPH_LADDER_PASTEBIN_WASM_SERVER_URL "ws://127.0.0.1:8765" CACHE STRING
"URL pastebin's WASM client connects to; must be a reachable ladder_pastebin_server.")
endif()
target_compile_definitions(ladder_pastebin_gui_wasm PRIVATE
MORPH_LADDER_PASTEBIN_WASM_SERVER_URL="${MORPH_LADDER_PASTEBIN_WASM_SERVER_URL}"
)
endif()
377 changes: 377 additions & 0 deletions examples/pastebin/README.md

Large diffs are not rendered by default.

109 changes: 109 additions & 0 deletions examples/pastebin/gui/main.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// SPDX-License-Identifier: Apache-2.0

/// @file
/// pastebin's desktop client shell: one `AppContext` (deployment mode from
/// `--server`), the two QML adapters `gui_lib/paste_qml_bridges.hpp` defines
/// built inside `ctx.onReady()`, and a `QQmlApplicationEngine` loading this
/// rung's own QML module (`Pastebin`, see `cmake/morph_add_rung.cmake`).
///
/// Usage:
/// @code
/// ladder_pastebin_gui # in-process backend
/// ladder_pastebin_gui --server ws://127.0.0.1:8765 # standalone server
/// @endcode
///
/// Everything below the deployment-mode choice is shared verbatim with
/// `gui_wasm/main_wasm.cpp` — the adapters, the schema document and the QML
/// module all live outside this file precisely so the two clients are one
/// program with two `main()`s (`examples/TESTING.md`, "same client code").

#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QVariant>

#include "gui/app_context.hpp"
#include "paste_qml_bridges.hpp"
#include "pastebin/db/database.hpp"

#include <cstdlib>
#include <memory>
#include <optional>

namespace {

/// @brief `--server <url>` if present, otherwise no url (in-process mode).
[[nodiscard]] std::optional<QUrl> serverUrlFromArgs(const QStringList& args) {
const auto index = args.indexOf(QStringLiteral("--server"));
if (index < 0 || index + 1 >= args.size()) {
return std::nullopt;
}
return QUrl{args.at(index + 1)};
}

} // namespace

int main(int argc, char** argv) {
QGuiApplication qtApp{argc, argv};

const auto serverUrl = serverUrlFromArgs(QCoreApplication::arguments());

// Local mode hosts `PasteModel` in this very process, so this process is
// also the one that has to point Lightweight at a database and apply the
// migrations — the same bootstrap `src/server/main.cpp` performs, for the
// same reason. `Remote` mode must *not* do it: the server owns the store,
// and a client opening the same SQLite file behind the server's back is
// exactly the second writer this rung's SQLITE_BUSY work exists to avoid.
//
// Local mode is deliberately the *smaller* deployment, not an equivalent
// one: `pastebin::app::App` (the durable action log and the periodic
// expiry sweep) lives only in the server binary. A Local-mode client
// therefore journals nothing, and an expired paste keeps appearing in the
// listing until something sweeps it — `ListPastes` filters on visibility
// only, and it is `ExpirePaste` that reclaims the row
// (`src/models/paste_model.cpp`). Opening one still fails correctly with
// "paste has expired", because `GetPaste`'s own atomic guard never depends
// on the sweep having run.
if (!serverUrl) {
const char* connectionString = std::getenv("PASTEBIN_DB");
pastebin::db::setup(connectionString != nullptr ? connectionString
: "DRIVER=SQLite3;Database=pastebin.db;Timeout=5000");
}

// Mirrors AppContext's own doc-comment construction pattern: pick the
// mode, then build every handler from inside onReady() — a Remote context
// is *not* usable the line after its constructor returns
// (docs/findings/017).
::morph::ladder::gui::AppContext ctx{
serverUrl ? ::morph::ladder::gui::AppContext::Mode{::morph::ladder::gui::Remote{.url = *serverUrl}}
: ::morph::ladder::gui::AppContext::Mode{::morph::ladder::gui::Local{.workers = 4}}};

QQmlApplicationEngine engine;
std::unique_ptr<pastebin::gui::FormsBridge> formsBridge;
std::unique_ptr<pastebin::gui::PasteBridge> pasteBridge;

ctx.onReady([&] {
formsBridge = std::make_unique<pastebin::gui::FormsBridge>(ctx.bridge(), ctx.executor());
pasteBridge = std::make_unique<pastebin::gui::PasteBridge>(ctx.bridge(), ctx.executor());
// Initial properties rather than context properties: the root object
// then declares what it needs, so the same Main.qml also loads with
// nothing wired up — which is exactly what the offscreen engine-load
// smoke test (tests/test_gui_qml_smoke.cpp) does.
engine.setInitialProperties({
{QStringLiteral("formsController"), QVariant::fromValue(formsBridge.get())},
{QStringLiteral("pasteController"), QVariant::fromValue(pasteBridge.get())},
});
engine.loadFromModule(MORPH_LADDER_QML_URI, "Main");
if (engine.rootObjects().isEmpty()) {
qWarning("ladder_pastebin_gui: QML engine produced no root object");
QCoreApplication::exit(1);
}
});

if (serverUrl) {
qInfo("ladder_pastebin_gui: connecting to %s ...", qUtf8Printable(serverUrl->toString()));
}
return QGuiApplication::exec();
}
201 changes: 201 additions & 0 deletions examples/pastebin/gui/qml/Main.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// SPDX-License-Identifier: Apache-2.0
//
// pastebin's desktop shell. Three panes' worth of behavior, none of it
// domain logic (examples/TESTING.md presenter rule 6, "QML is bindings-only"):
//
// * the create form is the shipped MorphForms renderer (DynamicForm) driven
// entirely by schemaJson<CreatePaste>() — nothing here knows CreatePaste
// has a `syntax` field, a burn budget, or an expiry;
// * the list and the detail pane are read-only displays of server-computed
// state relayed by PastePresenter (via gui/main.cpp's PasteBridge);
// * every error string shown is the model's own `what()`.
//
// `formsController` / `pasteController` are supplied by gui/main.cpp through
// QQmlApplicationEngine::setInitialProperties. They default to null so this
// same file also loads with nothing wired up, which is exactly what the
// offscreen engine-load smoke test (tests/test_gui_qml_smoke.cpp) does.

pragma ComponentBehavior: Bound

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import MorphForms

ApplicationWindow {
id: root
width: 980
height: 720
visible: true
title: "pastebin — morph application ladder, rung 1"

property var formsController: null
property var pasteController: null

property var schemas: root.formsController ? JSON.parse(root.formsController.schemasJson) : ({})
property var rows: []
property var currentPaste: null
property string status: ""
property bool statusIsError: false

function report(message, isError) {
root.status = message
root.statusIsError = isError
}

// The first listing cannot simply be requested from Component.onCompleted.
// In Remote mode AppContext::onReady() fires when the *socket* connects,
// which is when gui/main.cpp builds the presenters — but a BridgeHandler's
// registration is a round trip, and until its reply lands every dispatch
// through it fails fast with "handler not bound" (morph/core/bridge.hpp).
// Verified, not theorised: an unconditional refresh() on completion
// reliably reported exactly that error and left the list empty on every
// launch against a real server. `PasteBridge::bound` (backed by
// `Bridge::whenBound()`) is that round trip's settlement signal — Local
// mode's handler is already bound by construction, so this fires
// synchronously there.
Connections {
target: root.pasteController

function onBound() {
root.pasteController.refresh()
}

function onListed(rows) {
root.rows = rows
root.report("", false)
}

function onLoaded(paste) {
root.currentPaste = paste
root.report("opened " + paste.id + " — read " + paste.readCount + " time(s)", false)
// A read is a mutation in this rung: GetPaste consumes one unit of
// burn budget, and the read that spends the last unit destroys the
// paste server-side (README, "burn-after-read atomicity"). Re-listing
// is what makes that visible instead of leaving a stale row on screen.
root.pasteController.refresh()
}

function onRemoved() {
root.currentPaste = null
root.report("deleted", false)
root.pasteController.refresh()
}

function onFailed(message) {
root.report(message, true)
}
}

Connections {
target: root.formsController

// The create form submits through PasteFormsController, not through
// PastePresenter, so this — not `pasteController.created` — is where a
// create's outcome arrives.
function onReplyReceived(actionType, ok, payload) {
if (!ok) {
root.report(payload, true)
return
}
root.report(actionType + " ok: " + payload, false)
createForm.resetFields()
if (root.pasteController)
root.pasteController.refresh()
}
}

ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 8

Label {
Layout.fillWidth: true
visible: root.status !== ""
wrapMode: Text.Wrap
color: root.statusIsError ? "#d33" : palette.text
text: root.status
}

RowLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 8

ColumnLayout {
Layout.preferredWidth: 430
Layout.fillHeight: true
spacing: 8

DynamicForm {
id: createForm
Layout.fillWidth: true
actionType: "CreatePaste"
schema: root.schemas["CreatePaste"] || ({})
// Deliberately *not* `controller: root.formsController`.
// DynamicForm auto-submits the moment its required fields
// are engaged and on every keystroke after that — right for
// the calculator-shaped actions it was written against,
// catastrophic for CreatePaste, which would store one paste
// per typed character. Left unbound, the form is a pure
// renderer/validator: `ready` is its submit gate and
// `previewLine` is the exact JSON body it assembled, which
// the button below hands to the controller on demand.
controller: null
}

Button {
Layout.fillWidth: true
text: "Create paste"
enabled: root.formsController !== null && createForm.ready
onClicked: root.formsController.submitIfValid("CreatePaste", createForm.previewLine)
}

RowLayout {
Layout.fillWidth: true

Button {
text: "Refresh list"
enabled: root.pasteController !== null
onClicked: root.pasteController.refresh()
}

Label {
Layout.fillWidth: true
opacity: 0.7
text: root.rows.length + " public paste(s)"
}
}

ListView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
model: root.rows

delegate: ItemDelegate {
required property var modelData
width: ListView.view.width
text: modelData.id + " · " + modelData.syntax + " · " + modelData.visibility
+ " · " + modelData.createdAt
onClicked: {
if (root.pasteController)
root.pasteController.open(modelData.id)
}
}
}
}

PasteView {
Layout.fillWidth: true
Layout.fillHeight: true
paste: root.currentPaste
onDeleteRequested: pasteId => {
if (root.pasteController)
root.pasteController.remove(pasteId)
}
}
}
}
}
Loading
Loading