LWS is a compact C++26 windowing layer with explicit platform ownership. It provides portable window lifecycle, input, events, immutable cursors and icons, timers, clipboard, drag-and-drop, and bitmap presentation while keeping native backends private.
| Platform | Backend | Status |
|---|---|---|
| Windows | Win32 | Supported |
| Linux | Wayland | Supported when the Wayland development packages and protocols are available |
| Linux | X11 | Source scaffold only; it is not reported as an available backend |
PlatformContext::GetAvailableBackends() reports compiled complete backends. Context-scoped Supports() queries
optional behavior without consulting global state.
cmake -S . -B build -DLWS_BUILD_TESTS=ON
cmake --build build
ctest --test-dir build --output-on-failureEmbed LWS with add_subdirectory and link LWSLib.
#include <LWS/Platform.hpp>
#include <LWS/Window.hpp>
#ifdef LWS_HAS_WIN32_BACKEND
#include <LWS/Win32/Platform.hpp>
#endif
int main()
{
#ifdef LWS_HAS_WIN32_BACKEND
if (LWS::Win32::BootstrapProcess() != LWS::Result::Success)
return 1;
#endif
LWS::PlatformContext platform;
const LWS::PlatformConfig platformConfig{
#ifdef LWS_HAS_WIN32_BACKEND
.backend = LWS::BackendId::Win32,
#else
.backend = LWS::BackendId::Wayland,
#endif
};
if (platform.Init(platformConfig) != LWS::Result::Success)
return 1;
{
LWS::Window window(platform);
auto connection = window.Listen(
[&](const LWS::AnyEvent& event)
{
if (std::holds_alternative<LWS::EventWindowDestroyed>(event))
platform.RequestQuit();
return LWS::EventResponse::Unhandled;
});
const LWS::WindowConfig config{
.clientSize = {800, 600},
.styles = LWS::WindowStyleFlags(LWS::WindowStyle::Caption | LWS::WindowStyle::CloseButton),
.visible = true,
};
if (!connection.has_value() || window.Create(config) != LWS::Result::Success)
return 1;
platform.RunMessageLoop();
std::ignore = window.Destroy();
}
return platform.Shutdown() == LWS::Result::Success && !platform.GetFailure().has_value() ? 0 : 1;
}- One
PlatformContextowns one backend, one UI thread, one task queue, and one message loop. - Failed context initialization may retry. Successful shutdown is terminal.
- A
Windowpermanently borrows an active context and has at most one successful native lifetime. - Destroy every bound window and persistent service before shutting its context down.
- Context-bound operations require the context thread.
PostTask(),RequestQuit(), andIsCurrentThread()are the cross-thread-safe instance operations. - User callbacks execute synchronously on the context thread. Exceptions are reported through the installed non-throwing context handler and never unwind through native callbacks.
Window::PresentBitmap() borrows the caller's pixels only for the duration of the call. The caller may reuse or
release that storage on return. The bitmap must match the current framebuffer dimensions; Wayland accepts tightly
packed, top-down, premultiplied BGRA pixels.
Wayland copies each accepted frame directly into reusable shared memory. At most three presentation buffers are submitted to the compositor at once, with one additional unsubmitted buffer holding the newest pending frame. Repeated pending frames overwrite that idle buffer. When a submitted buffer is released, LWS submits the pending buffer itself, without another pixel copy. Released buffers are reused, and obsolete idle sizes are discarded.
The copying API is an intentional simplicity tradeoff. An optional acquire/write/present API could eliminate the remaining copy for clients that render directly into LWS shared memory. It was not selected because it would expose exclusive buffer access and compositor release coordination to clients, including buffer exhaustion, resize, cancellation, window destruction, and background-render completion. LWS keeps that coordination internal so clients can present ordinary pixel storage without managing presentation-buffer leases. Such an additive API remains a future option if a measured client workload justifies the additional contract.
Shared code uses Window, AnyEvent, ClientAreaMetrics, and logical client coordinates. WindowConfig::clientSize
and WindowPlacementRequest::clientSize describe the drawable client area with LogicalSize; native title bars,
borders, shadows, and other outer decorations are outside that size contract. ClientAreaMetrics::pixels is an
optional, non-convertible PixelSize and is authoritative for native rendering when present.
Logical size is the stable layout size an application aims to preserve when display scaling changes. On Win32, logical units are normalized to 96 DPI; on Wayland, they are surface coordinates. A width of 600 logical units uses 600 pixels at 100% scaling, 900 at 150%, and 1200 at 200%. Logical units do not measure inches or millimetres, and resizing, native window constraints, or integer rounding can still change the reported logical dimensions.
GetClientAreaMetrics() returns a ClientAreaMetrics value directly. When native metrics are available, it pairs
logical and pixel dimensions from the last published backend update. Size listeners observe that pair before
running; show-state listeners can run earlier and observe the previous pair. Nested dispatch can publish a newer
pair before an outer listener resumes. Otherwise, logical comes from the backend while created or
the stored configuration outside that state, and pixels is empty. Logical size remains useful for startup layout.
Pixels are unavailable before initial configuration, during Wayland remapping, and after native teardown or backend
failure. They can remain available during orderly cleanup even though IsConfigured() is false. An available
zero-sized client area is distinct from missing native metrics. On Wayland, later metrics updates can also result
from LWS applying size requests without a new compositor acknowledgment. Before initial configuration or while
remapping, size and scale requests update backend layout state without publishing paired metrics.
Use one snapshot for rendering dimensions and coordinate conversion so both use the same scale:
const auto metrics = window.GetClientAreaMetrics();
if (const auto scale = metrics.Scale(); scale && metrics.pixels->x > 0 && metrics.pixels->y > 0)
{
ResizeRenderTarget(*metrics.pixels);
const auto logicalPosition = window.GetMousePosition();
const double pixelX = logicalPosition.x * scale->x;
const double pixelY = logicalPosition.y * scale->y;
HandlePointerInPixels(pixelX, pixelY);
}ResizeRenderTarget() and HandlePointerInPixels() above represent application code. Scale() computes
pixels / logical on each axis, returning nullopt if pixels are absent or either logical dimension is zero.
Rounding can make these effective ratios
differ between axes and from nominal display scale; use the reported pixel size for render targets. On Win32,
pixel dimensions come from GetClientRect() or native resize notifications.
Wayland scale is compositor-provided per-surface state, not physical-monitor DPI. With wp_fractional_scale_v1 and
wp_viewporter, LWS allocates each pixel dimension with ceil(logical * preferredScale), uses buffer scale one, and
sets the viewport destination to the logical size. Without those protocols, LWS uses the maximum integer scale of the
outputs containing the surface. A scale change that alters the paired pixel size publishes
EventClientAreaSizeChanged even when logical size is unchanged; clients must use that pixel size rather than monitor
scale or reconstructed dimensions.
RequestPlacement() accepts optional position and logical client size: omitted fields stay unchanged, and an empty
request or nonpositive supplied size is invalid. Combined Win32 movement/resizing uses one native geometry operation;
redraw and callbacks retain their own timing. Placement does not issue a show-state request or override one made
by a geometry listener. Wayland top-level positions cannot be set or queried, so an explicit
position returns NotSupported without applying an accompanying resize. Wayland child position and content can take
effect on separate parent/child commits. GetPlacement() returns optional position and logical client size; it does
not promise an atomic native observation. Win32 top-level position follows the restored placement in DPI-normalized
screen coordinates, matching position requests, while child positions are relative to their parent's client area.
GetClientSizeLimits() and SetClientSizeLimits() exchange a ClientSizeLimits with minimum and maximum logical
dimensions. Zero means no application-specified limit on that axis. Values must be nonnegative, and a nonzero maximum
must be at least its minimum. Native constraints may still apply. Creation uses the same type through
WindowConfig::clientSizeLimits.
RequestShowState(WindowShowState::Maximized) requests a windowed, maximized top-level window even from fullscreen.
Win32 retains the current monitor and saved normal client size where it fits. Wayland sends both leaving-fullscreen
and maximization requests, even when earlier configuration events are in flight. Success means the request was
issued; the compositor controls the resulting state and timing. Child maximization returns NotSupported.
SetMouseCursor(Cursor::FromShape(CursorShape::Arrow)) resets the cursor while preserving its visibility.
SetWindowIcon(std::nullopt) clears the custom icon. Both preserve the previous reset operations' lifecycle and
platform support; a moved-from cursor or engaged moved-from icon remains invalid, and repeated immutable resources
retain native handle reuse.
Platform translation units may opt into typed extensions:
#ifdef LWS_HAS_WIN32_BACKEND
#include <LWS/Win32/WindowExtensions.hpp>
auto connection = LWS::Win32::Listen(
window,
[](const LWS::Win32::PlatformEvent& event) -> std::optional<LRESULT>
{
if (const auto* paint = std::get_if<LWS::Win32::PaintEvent>(&event))
{
DrawSidebar(paint->deviceContext, paint->invalidRect);
return 0;
}
return std::nullopt;
});
#endifTyped native handles are borrowed from successful creation until native teardown begins, including orderly
EventWindowDestroying notification. Release dependent swap chains, EGL surfaces, wl_egl_window objects, and native
registrations in that notification. Backend failure invalidates public handle access immediately.
-
File-dialog filters are a
ListFileDialogFilterscollection ofFileDialogFiltervalues. Store/pass that collection directly instead of constructing aFileDialogFilterBuilderand callingGetFilters(). -
Win32::GetHwnd()andWayland::GetSurface()takeconst Window&; mutable-window calls continue to work. -
Portable
AnyEventno longer includes the unusedEventRawPlatform; use the existing typed platform listeners. -
PlatformContext::AssertCurrentThread()is private; clients can queryIsCurrentThread(). -
Read backend identity through
window.GetPlatformContext().GetBackendId();Window::GetBackendId()was removed. The context getter returns an optional because contexts can exist before initialization; a window is permanently bound to an initialized context. -
Replace
Window::GetClientSize()/GetLogicalClientSize()withGetClientAreaMetrics().logical, andGetClientAreaSize()withGetClientAreaMetrics(). RenameClientAreaSizetoClientAreaMetrics; check its optionalpixelsandScale()instead of checking an outerexpectedresult. -
Replace
SetPosition(),RequestClientSize(), andSetPlacement()withRequestPlacement()supplying only the desired fields. UseGetPlacement().positioninstead ofGetPosition(). -
Replace
GetMinClientSize(),GetMaxClientSize(), andSetMinMaxClientSize()withGetClientSizeLimits()andSetClientSizeLimits(). ReplaceWindowConfig::minClientSize/maxClientSizewithclientSizeLimits.minimum/clientSizeLimits.maximum. -
Replace
ResetMouseCursor()withSetMouseCursor(Cursor::FromShape(CursorShape::Arrow)), andResetWindowIcon()withSetWindowIcon(std::nullopt). -
Replace
RequestMaximize()withRequestShowState(WindowShowState::Maximized). The latter now also leaves fullscreen and rejects child windows; restore and minimize behavior is unchanged. -
Boolean queries are
IsVisible(),IsTransparent(),IsAlwaysOnTop(), andIsBackgroundErasureEnabled(); drag-and-drop configuration isSetDragAndDropEnabled(bool). These replace the correspondingGet...()methods andEnableDragAndDrop(bool). These API migrations have no compatibility aliases; rebuild clients together. -
RunMessageLoop()andProcessMessages()returnLoopResult. A host loop must compare explicitly withContinue,Quit, orFailed; the old Boolean use ofProcessMessages()requires a source update.RunMessageLoop()never returnsContinue. Quit is sticky, including across repeated loop calls, and failure takes precedence. -
GetFailure()returns the first backend diagnostic, owned by the context and retained throughShutdown(). Failure rejects new posts and suppresses ordinary callbacks, including remaining listeners after nested pumping. Unstarted tasks are discarded; their captures are released after active dispatch unwinds or at later explicit shutdown. -
Failure does not destroy clients' C++ objects.
IsCreated()/IsConfigured()and handle access become unavailable; clients destroy their object graph normally. Cleanup receivesEventWindowDestroying{false}when the backend is already lost. No application exit, restart, or backend migration is performed by LWS. -
EventWindowDestroyingis appended toAnyEvent. Update exhaustive visitors and rebuild clients and libraries together; this change does not claim binary compatibility with earlier headers. Embedded LLUtils must include the accompanyingEvent::RaiseWhile()addition used to stop notification listeners after failure. -
On orderly destruction, the parent is notified before its children, then its native window is destroyed and the final destroyed event is delivered. Pre-destruction cannot be consumed or cancelled. Unexpected cleanup exceptions are reported and later eligible cleanup listeners continue if the handler returns; without a handler LWS terminates.
-
Recursive destruction of a destroying window returns
InvalidState. Destroying descendants during an ancestor's pre-destruction notification, or an ancestor while a descendant is destroying, is rejected. Supported sibling removal remains valid once ancestor pre-destruction notification has completed. -
Native
Destroy()may run from callbacks, but the executing C++ Window must survive all active native/event dispatch. Deleting it, or the active context, is a debug-diagnosed precondition violation. Deferring deletion by posting a task alone is insufficient if nested pumping can execute the task early. Existing supported timer self-deletion remains. -
Normal
Shutdown()rejects active dispatch or bound objects. Otherwise it closes task acceptance and drains accepted tasks. New posts during the drain are rejected. Resources left bound by those tasks keep a healthy context active and reopen acceptance for cleanup/retry. Successful shutdown is terminal. Failure during the drain discards the rest. -
Active scheduling remains unchanged. Do expensive work on background threads and post brief, nonblocking result application callbacks. The queue is unbounded; callers own workload discipline.
All operations retain their documented context-thread precondition. A failed context overrides native access in every phase. Typed native handles must not be used to destroy LWS-owned windows directly.
On Wayland, style flags describe requests and local-frame policy. With server-side decorations, the compositor controls
caption buttons; GetWindowStyles() does not claim every requested decoration was enforced by the compositor.
| Property/operation | Pre-create | Created | Orderly cleanup notification | Destroyed or backend failed |
|---|---|---|---|---|
| Context/backend identity | Available | Available | Available | Available until C++ destruction |
| Title, optional position, logical size, placement, min/max, styles, show state, visibility, transparency, always-on-top, erase flag | Stored defaults | Current backend values | Last stored request/observed value | Last stored request/observed value |
| Window mode | Backend's retained logical mode | Current mode | Retained mode | Retained mode |
| Client-area metrics | Logical defaults; pixels absent | Logical size always available; paired pixels after configuration | Last configured pair before native teardown | Stored logical size; pixels absent |
| Parent | None until successful creation | Bound parent | Relationship remains during notification | Cleared after final destroyed dispatch; retained until cleanup on failure |
| Focus/pointer-in-client | False | Current observation | False | False |
| Mouse position | Zero | Current observation | Zero | Zero |
| Typed handles | InvalidState | Borrowed | Borrowed until native teardown starts | InvalidState |
| Cursor/icon selection, visibility and typed app-ID/menu configuration | Allowed where supported | Allowed where supported | InvalidState | InvalidState |
| Other window mutations, presentation, drag/resize/lock requests | InvalidState | Validated operation | InvalidState | InvalidState |
| Listen | Allowed | Allowed | InvalidState | InvalidState |
| Disconnect existing listener | Allowed | Allowed | Allowed | Allowed |
These are retained logical values, not a promise to query a disconnected native system. Construction events stay
suppressed and failed Create() remains retryable; successful native lifetime is one-shot. Unsupported requests return
NotSupported without changing retained/native state. Malformed arguments use InvalidArgument, and lifecycle errors
use InvalidState. Service APIs with legacy non-Result signatures preserve their failure conventions: timers cannot
rearm after failure, clipboard operations return empty/error results, and notification creation reports invalid state.
Ordinary portable and typed dispatch read stable vectors without allocating a snapshot per event. Additions made during any nested listener traversal on a window accumulate in a pending list; it is published when the outermost traversal completes. Publication precedes retired-capture destruction, so events reentered from those destructors see the published list; additions made during that cleanup are deferred until the cleanup finishes. This intentionally changes nested ordinary-event visibility: a newly registered listener does not receive events in the current dispatch chain. Its connection is immediately queryable/disconnectable, and disconnection immediately suppresses later invocation. Destruction notifications include pending listeners, preserving cleanup for resources and timers attached just before a callback destroys its window. Window rejects further registration during that cleanup phase.
Retired callbacks are released outside vector mutation, and Close cannot revive pending registrations. Listener operations remain context-thread-affine; this adds no listener locks or atomic publication. The tradeoff is extra per-window pending state and more work on registration changes in exchange for removing steady-state vector allocation/copying.
Reapplying the same immutable custom cursor or icon to a created Win32 window reuses that window's native allocation. Cursor visibility and WM_SETICON application still run. Creation/retry and replacement realize resources normally; each window retains independent native resources. Custom cursor bitmaps/hotspots are pixel data and do not depend on window DPI. Wayland's focus/serial/theme/scale-driven cursor application is unchanged.
When wayland-server development files are available, LWS tests build a private protocol server for deterministic seat
capability/removal/promotion, cursor focus/serial, and integer/fractional-scale checks. This dependency is test-only. Win32
DPI scenarios run in separate child processes because process DPI initialization is irreversible.
| Path | Purpose |
|---|---|
src/LWS/include/LWS |
Portable public headers and typed extension headers |
src/LWS/source |
Portable implementation and private native backends |
tests |
Unit and platform integration tests |