// lesson: the-platform-seam

The Platform Seam

Before touching an OS API, decide where portability lives. The classic mistake is to sprinkle #ifdef _WIN32 through the whole program until every file knows about three operating systems. The fix is one deliberate seam: a small set of types the editor is written against, with one implementation of that seam per platform. Get the seam right and the editor core โ€” the document, layout, selection, undo, everything you'll build from lesson 6 onward โ€” compiles and runs on a machine with no display at all. (That's not hypothetical: it's exactly how this course's grader runs your code.)

A workable seam for an editor is tiny:

// platform.h โ€” the only header the editor core sees.
struct PlatformWindow {
    virtual ~PlatformWindow() = default;
    virtual void set_title(const std::string& title) = 0;
    virtual void invalidate(Rect dirty) = 0;   // "please send me a Paint for this"
    virtual Size size() const = 0;
    virtual void blit(const Framebuffer& fb) = 0;  // pixels -> screen
};

struct Platform {
    virtual ~Platform() = default;
    virtual std::unique_ptr<PlatformWindow> create_window(int w, int h) = 0;
    virtual std::optional<Event> wait_event() = 0;  // nullopt = shutting down
    virtual std::string clipboard_read() = 0;
    virtual void clipboard_write(const std::string& text) = 0;
};

Two C++ decisions are hiding in those dozen lines, and they're worth making consciously:

  • Abstract base vs std::variant. You could model the seam as std::variant<X11Platform, Win32Platform, CocoaPlatform> and std::visit your way through. But only one alternative can ever exist per build โ€” you'll never hold "an X11 or Win32 platform" at runtime on one OS โ€” so the variant buys closed-set exhaustiveness you don't need and costs you a compile-time dependency on every platform's headers everywhere. An abstract base with exactly one derived class linked per platform keeps each backend's headers quarantined in its own .cpp (or .mm) file. Variants shine for events (a closed set of small value types); virtual dispatch shines for backends (open set, one alive at a time, holding OS resources).
  • Pimpl by construction. Because the editor only ever sees PlatformWindow*, the xcb_connection_t* and xcb_window_t members live in X11Window : PlatformWindow inside platform_x11.cpp. The interface is the pimpl โ€” no separate idiom needed, no OS header ever leaks into the core's translation units.

Value types for geometry

The seam traffics in points, sizes, and rectangles constantly: mouse positions, dirty regions, window sizes. These should be value types โ€” small structs passed by value, compared field by field, copied freely โ€” not objects with identity. There is nothing to encapsulate in an x and a y; wrapping them in getters adds friction and nothing else. This is the "rule of zero" end of the design spectrum: no destructor, no copy control, aggregate initialization (Rect{0, 0, 800, 600}) just works.

The one place geometry earns real code is rectangle algebra. Editors compute with rects all day: "clip this fill to the window", "what part of the damage is visible", "merge these two dirty regions". Two operations carry almost all of it:

  • intersect(a, b) โ€” the overlapping region (used for clipping). If the rects don't overlap, the result is empty.
  • union_of(a, b) โ€” the smallest rect containing both (used for merging damage). An empty rect is the identity: union with it returns the other rect unchanged, because "no damage" plus "this damage" is just "this damage".

A rect with w <= 0 or h <= 0 is empty. Treat all empty rects as interchangeable โ€” code that checks r.empty() before using r never cares which degenerate coordinates it holds.

One convention to fix now and never revisit: rects are half-open. A rect at x=0 with w=10 covers pixel columns 0 through 9 โ€” x + w is one-past-the-end, exactly like STL iterators. Half-open ranges make adjacency tests (a.x + a.w == b.x) and width math (right - left == width) come out without the ยฑ1 fudging that plagues inclusive ranges.

Handles: what the OS actually hands you

Everything a windowing system gives you is a handle: an opaque token standing for a resource the OS owns on your behalf. On X11 a window is a uint32_t ID valid only within one connection; on Win32 it's an HWND; on macOS an NSWindow*. The same goes for graphics contexts, shared-memory segments, and the file-watch descriptors we'll meet in the final lesson. Each has a matching destroy call (xcb_destroy_window, DestroyWindow, close), and each one leaks โ€” or worse, dangles โ€” if some error path misses the cleanup.

C programs handle this with discipline and goto cleanup. C++ has a better answer, and it's the most important idiom in this course's platform layer: RAII โ€” acquire the resource in a constructor, release it in the destructor, let scope do the bookkeeping. Once a handle lives inside an RAII wrapper there is no code path that leaks it: early returns, exceptions, and error branches all run the destructor.

The subtlety is copying. If a wrapper owning window #42 gets copied, both copies' destructors destroy window #42 โ€” a double-free. So an owning handle wrapper must be move-only: copying is deleted (there can't be two owners), and moving transfers ownership, leaving the source holding nothing so its destructor does nothing. This is the "rule of five" in its most common practical form: you wrote a destructor, so you must decide all five special members. (The geometry structs above follow the rule of zero: no resources, so no special members at all. Most types should be one or the other โ€” a type with a destructor but default-generated copies is the red flag.)

You already know this shape: it's std::unique_ptr, generalized. A unique_ptr with a custom deleter can wrap handles, but an int-valued handle where -1 means "nothing" fits awkwardly into a pointer-shaped API, so platform layers routinely write a small dedicated wrapper. Three fine points the second challenge will hold you to:

  • Moved-from means empty. After b = std::move(a), a must hold the invalid handle so its destructor is a no-op. Forgetting this is the #1 RAII bug: the resource dies twice.
  • Move-assignment releases the old resource first. If b already owned something, that something must be closed before b takes over a's handle โ€” otherwise it leaks silently.
  • Self-move must not destroy the resource. x = std::move(x) is legal C++ (it arises in generic code) and must leave x usable.

release() (give the handle up without closing โ€” for handing ownership to an API that takes it) and reset() (close now, optionally adopt a new handle) round out the interface, mirroring unique_ptr deliberately: familiar names make your code cheaper to read.

โ€บ Points and Rects

10 pts

Implement the geometry kit the rest of the course leans on. Every later rendering challenge hands you this code back pre-written, so make it right once, here.

Semantics:

  • Rect::empty() โ€” true iff w <= 0 or h <= 0.
  • Rect::contains(Point) โ€” half-open on both axes: {0,0,10,10} contains (0,0) and (9,9) but not (10,10). Empty rects contain nothing.
  • intersect(a, b) โ€” the overlap; when there is none, return something empty() (returning {0,0,0,0} is the tidy choice). If either input is empty, the result is empty. The tests only inspect exact fields of non-empty results.
  • union_of(a, b) โ€” bounding box of both; if one input is empty, return the other unchanged; if both are empty, return an empty rect.

Log in to submit a solution and earn points.

โ€บ A Move-Only Handle

12 pts

Implement UniqueHandle, the RAII wrapper the platform backends use for every OS resource. The handle is an int where -1 means "none"; the close function is supplied at construction (in the real backend it's a function calling xcb_destroy_window, close(2), and so on).

Required behavior:

  • the destructor closes a valid handle exactly once; it never calls close for handle -1 or when the close function is null;
  • move construction and move assignment transfer ownership and leave the source empty;
  • move assignment closes the destination's previous handle first;
  • self-move-assignment leaves the object unchanged and closes nothing;
  • release() returns the handle and empties the wrapper without closing;
  • reset(h, fn) closes any current handle, then adopts h and fn.

Log in to submit a solution and earn points.