sunflower

Version, currently master branch1 version
  • master branchlatestMar 27, 2026

github.com/grkek/sunflower

A lightweight desktop application framework that pairs GTK4 with a JavaScript engine

15 stars
0 dependents
License: MIT

Installation

# Add this to your shard.yml
dependencies:
  sunflower:
    github: grkek/sunflower
    branch: master

master is a branch, not a release, so this tracks it rather than pinning a version.

Then run:

shards install

shard.yml

Crystal
1.18.2
License
MIT
Author
Giorgi Kavrelishvili <giorgi.kavrelishvili@pm.me>

Dependencies

Runtime Dependencies

  • gtk4*github: hugopl/gtk4.cr
  • medusa*github: grkek/medusa
  • tachyon*github: grkek/tachyon

Development Dependencies

  • ameba*github: crystal-ameba/amebadev

README

# Sunflower

A lightweight desktop application framework that pairs **GTK4** with a **JavaScript** engine. Write your UI in declarative XML markup or **JSX components**, style it with CSS, and bring it to life with JavaScript — all without the overhead of a browser engine.

Sunflower is built with [Crystal](https://crystal-lang.org) and uses [QuickJS](https://bellard.org/quickjs/) (via [Medusa](https://github.com/grkek/medusa)) as its embedded JavaScript runtime.

## Why Sunflower?

| | Sunflower | Electron |
|---|---|---|
| Memory (idle) | ~10–30 MB | ~80–150 MB |
| Runtime | QuickJS (~2 MB) | Chromium (~200 MB) |
| UI Layer | Native GTK4 | Embedded browser |
| Language | Crystal + JS | Node.js + JS |

Sunflower gives you a JS-scriptable desktop application with native widgets and a fraction of the resource cost.

## Quick Start

### Prerequisites

- [Crystal](https://crystal-lang.org/install/) (>= 1.10)
- GTK4 development libraries
- GLib development libraries

### Installation

```bash
git clone https://github.com/grkek/sunflower.git
cd sunflower
shards install
./bin/gi-crystal
```

### Hello World

Create a project with the following structure:

```
my-application/
└── dist/
  └── index.html
  └── scripts/
        └── App.jsx
└── src/
  └── application.cr
```

**`application.cr`** — your entry point:

```crystal
require "sunflower"

Log.setup do |c|
  backend = Log::IOBackend.new(STDERR, formatter: Log::ShortFormat, dispatcher: :sync)
  c.bind("*", :debug, backend)
end

builder = Sunflower::Builder.new
builder.build_from_file(File.join(__DIR__, "..", "dist", "index.html"))
```

**`src/index.html`** — your UI:

```xml
<Application applicationId="com.example.hello">
  <Window title="Hello Sunflower" width="400" height="300">
    <Box id="root" orientation="vertical" expand="true" />
  </Window>
  <Script src="scripts/App.jsx" />
</Application>
```

**`src/scripts/App.jsx`** — your App:

```jsx
import Stigma, { useState } from "stigma";

function App() {
  const [count, setCount] = useState(0);

  return (
    <Box orientation="vertical" spacing="12">
      <Label>{"Clicked " + count + " times!"}</Label>
      <Button onPress={function() { setCount(count + 1); }}>
        Click Me
      </Button>
    </Box>
  );
}

Stigma.onReady(function() {
  Stigma.render("root", App);
});
```

Run it:

```bash
GTK_DEBUG=interactive crystal run ./src/application.cr -Dpreview_mt
```

## Three Modes

Sunflower supports three development styles:

### 1. Markup Mode

Define your UI in XML with inline or external scripts. Best for simpler apps or when you want a clear separation between structure and logic. In markup mode, components are accessed through the `Runtime` global which is available without any imports.

```xml
<Application applicationId="com.example.app">
  <StyleSheet src="styles/index.css" />
  <Window title="My App" width="800" height="600">
    <Box orientation="vertical">
      <Label id="title">Hello!</Label>
      <Button id="button">Click</Button>
    </Box>
  </Window>
  <Script src="scripts/index.js" />
</Application>
```

```javascript
// scripts/index.js — markup mode, no imports needed
Runtime.onReady(function() {
  var button = Runtime.getComponentById("button");
  button.on.press = function() {
    Runtime.getComponentById("title").setText("Clicked!");
  };
});
```

### 2. Component Mode (no JSX)

Use the full Stigma runtime — hooks, virtual DOM, reconciler — without JSX syntax. Write `createElement` calls directly in plain `.js` files. Same power as JSX mode, just without the syntactic sugar.

**`src/index.html`**:

```xml
<Application applicationId="com.example.app">
  <StyleSheet src="styles/index.css" />
  <Window title="My App" width="800" height="600">
    <Box id="root" orientation="vertical" expand="true" />
  </Window>
  <Script src="scripts/App.js" />
</Application>
```

**`scripts/App.js`**:

```javascript
import Stigma, { createElement, useState } from "stigma";

function Counter() {
  const [count, setCount] = useState(0);

  return createElement("Box", { orientation: "vertical", spacing: "12" },
    createElement("Label", { className: "title" }, "Count: " + count),
    createElement("Button", { onPress: function() { setCount(count + 1); } }, "Increment")
  );
}

Stigma.onReady(function() {
  Stigma.render("root", Counter);
});
```

This is useful when you prefer not to use JSX, want to avoid the transpiler, or are generating UI programmatically.

### 3. JSX Mode

Define your UI as composable function components with JSX syntax. This is syntactic sugar over Component Mode — the built-in transpiler converts JSX to `createElement` calls automatically.

**`src/index.html`**:

```xml
<Application applicationId="com.example.app">
  <StyleSheet src="styles/index.css" />
  <Window title="My App" width="800" height="600">
    <Box id="root" orientation="vertical" expand="true" />
  </Window>
  <Script src="scripts/App.jsx" />
</Application>
```

**`scripts/App.jsx`**:

```jsx
import Stigma, { useState } from "stigma";

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <Box orientation="vertical" spacing="12">
      <Label className="title">Count: {count}</Label>
      <Button onPress={function() { setCount(count + 1); }}>
        Increment
      </Button>
    </Box>
  );
}

Stigma.onReady(function() {
  Stigma.render("root", Counter);
});
```

The JSX transpiler runs automatically for `.jsx` files — no build step required. Under the hood, the JSX above compiles to the same `createElement` calls shown in Component Mode.

## Architecture

```
┌─────────────────────────────────────┐
│           JavaScript (QuickJS)      │  Your application logic
├─────────────────────────────────────┤
│           Crystal Bridge            │  Bindings, async promises, IPC
├─────────────────────────────────────┤
│           GTK4 (Native)             │  Rendering, input, styling
└─────────────────────────────────────┘
```

The Crystal bridge connects GTK4 widgets to JavaScript objects. Every widget gets a corresponding JS object with methods and event handlers. Async operations use a promise-based bridge — Crystal spawns a fiber, does the work, and resolves the JS promise when done.

The runtime is split into two layers:

- **`Runtime`** — a lightweight global object that the Crystal bridge writes to. Handles windows, lifecycle callbacks, and component lookups. Always available, no imports needed.
- **`"stigma"` module** — the full JSX runtime including hooks, virtual DOM, reconciler, and rendering. Loaded lazily on first import — markup-only apps never pay for it.

## Markup

Sunflower uses an XML-based markup language. Every application starts with an `<Application>` root containing a `<Window>`.

### Available Components

| Component | Description |
|---|---|
| `Application` | Root element. Requires `applicationId`. |
| `Window` | Application window. Attributes: `title`, `width`, `height`. |
| `Box` | Flex container. Attributes: `orientation` (`vertical`/`horizontal`), `spacing`, `homogeneous`. |
| `Button` | Clickable button. Events: `press`. |
| `Label` | Text display. Supports markup. |
| `Entry` | Text input field. Events: `change`. Attributes: `inputType="password"`. |
| `Image` | Displays images from local paths or URLs. |
| `ListBox` | Scrollable list container. |
| `ScrolledWindow` | Scrollable container for overflow content. |
| `Frame` | Visual grouping container with optional label. |
| `Tab` | Tabbed container. |
| `Switch` | Toggle switch. |
| `Canvas` | GPU-accelerated 2D drawing surface for games and visualizations. |
| `HorizontalSeparator` | Horizontal divider line. |
| `VerticalSeparator` | Vertical divider line. |

All components support self-closing syntax: `<Box />`, `<Entry />`, `<StyleSheet src="..." />`.

### Attributes

Every component supports:

- `id` — Unique identifier for JS access
- `className` — CSS class for styling
- `expand` — Whether the widget expands to fill available space
- `horizontalAlignment` — `"center"`, `"start"`, `"end"`, `"fill"`
- `verticalAlignment` — `"center"`, `"start"`, `"end"`, `"fill"`

### Scripts

Embed JavaScript inline or load from a file:

```xml
<!-- Inline -->
<Script>
  console.log("Hello from Sunflower!");
</Script>

<!-- External JS -->
<Script src="scripts/index.js" />

<!-- External JSX (auto-transpiled) -->
<Script src="scripts/App.jsx" />
```

### Stylesheets

Style your application with GTK CSS:

```xml
<!-- Inline -->
<StyleSheet>
  .my-button {
    background-color: #3584e4;
    color: white;
    border-radius: 6px;
    padding: 8px 16px;
  }
</StyleSheet>

<!-- External -->
<StyleSheet src="styles/index.css" />
```

## JavaScript API

### ES Module Imports

Sunflower's standard library is available as ES module imports:

```javascript
import Stigma, { useState, useEffect } from "stigma";
import { Canvas } from "canvas";
import { read, write, exists, mkdir } from "fs";
import { get, post, download } from "http";
```

The module loader checks built-in modules first, then falls back to loading `.js` files from disk for user modules.

### Import Styles

The `"stigma"` module supports multiple import patterns, similar to React:

```javascript
// Default import — the full Stigma object
import Stigma from "stigma";
Stigma.render("root", App);
Stigma.useState(0);
Stigma.onReady(function() { });

// Named imports — destructured
import { useState, useEffect, render, onReady } from "stigma";

// Both (recommended for JSX)
import Stigma, { useState, useEffect } from "stigma";
```

### Available Imports from `"stigma"`

| Export | Description |
|---|---|
| `createElement` | Virtual DOM node constructor (used by JSX transpiler) |
| `Fragment` | Fragment component for grouping without a wrapper |
| `useState` | State hook for function components |
| `useEffect` | Effect hook for side effects |
| `render` | Mount a component into a container |
| `onReady` | Register a callback for when the app is ready |
| `onExit` | Register a callback for when the app exits |
| `getComponentById` | Get a component by ID (optionally scoped to a window) |
| `findComponentById` | Search all windows for a component |

### The `Runtime` Object

The `Runtime` global is the internal bridge between Crystal and JavaScript. It's always available without imports and is useful in markup mode scripts:

```javascript
// Component access
var button = Runtime.getComponentById("myButton");
var label = Runtime.getComponentById("title", "Main");

// Window access
Runtime.windows["Main"];
Runtime.getWindow("Main");

// Lifecycle
Runtime.onReady(function() { });
Runtime.onExit(function() { });
```

In JSX mode, prefer importing from `"stigma"` instead of using `Runtime` directly.

### Event Handlers

Attach handlers through the `on` property:

```javascript
import Stigma from "stigma";

Stigma.getComponentById("myButton").on.press = function() {
  console.log("Button pressed!");
};

Stigma.getComponentById("myEntry").on.change = function(text) {
  console.log("Text changed: " + text);
};
```

### Component Methods

#### Button

```javascript
var button = Stigma.getComponentById("myButton");
button.setText("New Label");
```

#### Label

```javascript
var label = Stigma.getComponentById("myLabel");

label.setText("Plain text");
label.setLabel("Text with <b>markup</b>");
label.getText();
label.setWrap(true);
label.setEllipsize("end");
label.setXAlign(0.5);
label.setYAlign(0.5);
```

#### Entry

```javascript
var entry = Stigma.getComponentById("myEntry");

entry.setText("Default value");
var text = entry.getText();
entry.isPassword(true);
```

#### Image

```javascript
var img = Stigma.getComponentById("myImage");

// Load from URL (async)
await img.setResourcePath("https://example.com/photo.jpg");

// Load from local file
await img.setResourcePath("/path/to/image.png");

// Set content fit
img.setContentFit("cover"); // "fill", "contain", "cover", "none"
```

#### Box

```javascript
var box = Stigma.getComponentById("myBox");
box.append("childComponentId");
box.destroyChildren();
```

#### ListBox

```javascript
var list = Stigma.getComponentById("myList");
list.removeAll();
```

#### Window

```javascript
import { Window } from "stigma";

Window.setTitle("New Title");
Window.maximize();
Window.minimize();
Window.fullscreen();
Window.unfullscreen();
```

#### Universal Methods

Available on all components:

```javascript
var component = Stigma.getComponentById("any");

component.setVisible(false);
component.addCssClass("highlighted");
component.removeCssClass("highlighted");
```

### Component State

Every component has a lazy `state` getter that reads the current widget state from GTK:

```javascript
var button = Stigma.getComponentById("myButton");
console.log(button.state);
```

### Lifecycle

```javascript
import Stigma from "stigma";

// Run code when the application is ready (all components mounted)
Stigma.onReady(function() {
  console.log("I am ready!");
});

// Run code on exit (supports multiple callbacks)
Stigma.onExit(function() {
  console.log("Goodbye!");
});
```

### Async / Await

Sunflower has full async/await support. Any Crystal binding that does I/O returns a JS Promise that you can `await`:

```javascript
import Stigma from "stigma";

Stigma.onReady(async function() {
  await img.setResourcePath("https://example.com/photo.jpg");
  console.log("Image loaded!");
});
```

## JSX Components

### Setup

Create a minimal HTML shell with a root container, then write your UI in `.jsx` files:

```xml
<Application applicationId="com.example.app">
  <StyleSheet src="styles/index.css" />
  <Window title="My App" width="800" height="600">
    <Box id="root" orientation="vertical" expand="true" />
  </Window>
  <Script src="scripts/App.jsx" />
</Application>
```

### Function Components

Components are plain functions that return JSX. JSX files must import from `"stigma"` — the transpiler converts JSX syntax to `createElement()` calls:

```jsx
import Stigma from "stigma";

function Greeting({ name }) {
  return (
    <Box orientation="vertical">
      <Label className="title">Hello, {name}!</Label>
    </Box>
  );
}
```

### useState

Manage component state with `useState`:

```jsx
import Stigma, { useState } from "stigma";

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <Box orientation="vertical">
      <Label>Count: {count}</Label>
      <Button onPress={function() { setCount(count + 1); }}>+1</Button>
      <Button onPress={function() { setCount(0); }}>Reset</Button>
    </Box>
  );
}
```

### useEffect

Run side effects after render:

```jsx
import Stigma, { useState, useEffect } from "stigma";

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(function() {
    console.log("Timer mounted");
    return function() {
      console.log("Timer unmounted");
    };
  }, []);

  return <Label>Elapsed: {seconds}s</Label>;
}
```

### Composing Components

Nest components and pass properties:

```jsx
import Stigma from "stigma";

function UserCard({ name, email }) {
  return (
    <Box orientation="vertical" className="card">
      <Label className="name">{name}</Label>
      <Label className="email">{email}</Label>
    </Box>
  );
}

function App() {
  return (
    <Box orientation="vertical">
      <UserCard name="Giorgi" email="giorgi@example.com" />
      <UserCard name="Alice" email="alice@example.com" />
    </Box>
  );
}

Stigma.onReady(function() {
  Stigma.render("root", App);
});
```

### Conditional Rendering

```jsx
import Stigma, { useState } from "stigma";

function App() {
  const [loggedIn, setLoggedIn] = useState(false);

  if (loggedIn) {
    return <Label>Welcome back!</Label>;
  }

  return (
    <Button onPress={function() { setLoggedIn(true); }}>
      Sign In
    </Button>
  );
}
```

### Event Handlers in JSX

```jsx
<Button onPress={handleClick}>Click Me</Button>
<Entry onChange={function(text) { setQuery(text); }} />
```

### Fragments

Group elements without adding a wrapper widget:

```jsx
// Named tag
<Fragment>
  <Label>First</Label>
  <Label>Second</Label>
</Fragment>

// Shorthand syntax
<>
  <Label>First</Label>
  <Label>Second</Label>
</>
```

### Mounting

Mount your root component into a container defined in the HTML:

```jsx
import Stigma from "stigma";

Stigma.onReady(function() {
  Stigma.render("root", App);
});
```

## 2D/3D Game Engine

Sunflower includes a GPU-accelerated 2D Canvas for building games and interactive visualizations. Rendering is done through OpenGL via GTK4's `GLArea` widget with batched draw calls.

Take a look at the examples or the [Tachyon](https://github.com/grkek/tachyon) library for further information.

## Built-in Modules

Sunflower's standard library is available as ES module imports.

### `stigma` — Runtime & Hooks

The Stigma module is the JSX runtime. It loads lazily on first import — markup-only apps never pay for the reconciler, hooks, or virtual DOM overhead.

```javascript
import Stigma, { useState, useEffect, Window } from "stigma";

// State management
const [value, setValue] = useState(initialValue);

// Side effects
useEffect(function() {
  // setup
  return function() { /* cleanup */ };
}, [dependencies]);

// Mount a component
Stigma.render("containerId", MyComponent);

// Lifecycle
Stigma.onReady(function() { /* app is ready */ });
Stigma.onExit(function() { /* app is closing */ });

// Component access
Stigma.getComponentById("myButton");
```

### `fs` — File System

```javascript
import { read, write, append, exists, remove, mkdir, readdir, stat, writeBytes, readBytes } from "fs";

// Read / write / append
const content = await read("/path/to/file.txt");
await write("/path/to/file.txt", "Hello!");
await append("/path/to/log.txt", "New entry\n");

// Check existence and delete
const exists = await exists("/path/to/file.txt");
await remove("/path/to/file.txt");

// Directories
await mkdir("/path/to/new/dir");
const entries = await readdir("/path/to/dir");

// File info
const info = await stat("/path/to/file.txt");
console.log(info.size);
console.log(info.isFile);
console.log(info.isDirectory);
console.log(info.modifiedAt);

// Binary data
await writeBytes("/path/to/file.bin", new Uint8Array([0x89, 0x50, 0x4E, 0x47]));
const bytes = await readBytes("/path/to/file.bin");
```

### `http` — Networking

```javascript
import { get, post, put, patch, del, request, download } from "http";

// GET
const res = await get("https://api.example.com/data");
console.log(res.status);
console.log(res.body);
console.log(res.headers);

// GET with headers
const res = await get("https://api.example.com/data", {
  "Authorization": "Bearer token123"
});

// POST JSON
const res = await post("https://api.example.com/users",
  { name: "Giorgi" },
  { "Content-Type": "application/json" }
);

// PUT, PATCH, DELETE
await put(url, body, headers);
await patch(url, body, headers);
await del(url, headers);

// Generic request
const res = await request({
  url: "https://api.example.com/resource",
  method: "PATCH",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ updated: true })
});

// Download a file
const dl = await download("https://example.com/image.png", "/tmp/image.png");
console.log("Downloaded " + dl.bytes + " bytes to " + dl.path);
```

### `canvas` — 2D Game Engine

```javascript
import { Canvas } from "canvas";

const canvas = new Canvas("myCanvas", { width: 800, height: 600, framesPerSecond: 60 });

canvas.onUpdate(function(dt) { /* game logic */ });
canvas.onDraw(function(context) { /* rendering */ });
canvas.start();
```

See the [2D Game Engine](#2d-game-engine) section for the full API reference.

### Error Handling

All async module calls return an `error` field on failure instead of throwing:

```javascript
import { get } from "http";
import { read } from "fs";

const res = await get("https://invalid.example.com");
if (res.error) {
  console.error("Request failed: " + res.error);
}

const content = await read("/nonexistent/file.txt");
if (content.error) {
  console.error("Read failed: " + content.error);
}
```

## Console

Standard `console` methods are available:

```javascript
console.log("Info message");
console.info("Same as log");
console.debug("Same as log");
console.warn("Warning message");   // stderr with [WARN] prefix
console.error("Error message");    // stderr with [ERROR] prefix

// Objects are automatically JSON-serialized
console.log({ key: "value" });     // {"key":"value"}
```

## IPC

Sunflower exposes a UNIX socket for inter-process communication. External processes can send JSON messages to evaluate JavaScript in the running application.

### Message Format

```json
{
  "id": "unique-request-id",
  "directory": "/path/to/project",
  "file": "src/index.html",
  "line": 1,
  "sourceCode": "Runtime.getComponentById('title').setText('Updated from IPC!')"
}
```

| Field | Type | Description |
|---|---|---|
| `id` | `string` | Unique identifier for the request |
| `directory` | `string` | Project directory path |
| `file` | `string` | Source file that triggered the request |
| `line` | `int` | Line number in the source file |
| `sourceCode` | `string` | JavaScript code to evaluate |

### Example

```bash
echo '{"id":"1","directory":".","file":"repl","line":1,"sourceCode":"console.log(Runtime.componentIds)"}' \
  | socat - UNIX-CONNECT:/tmp/<socket-id>.sock
```

The socket path is logged on startup. IPC evaluates code in the global scope, so use `Runtime` for component and window access.

## How It Works

### The Two-Layer Runtime

Sunflower's JavaScript runtime is split into two layers:

- **`Runtime`** (global) — The Crystal bridge target. A lightweight object created eagerly at startup that holds the window registry, lifecycle callbacks, and component lookup functions. Crystal writes directly into `Runtime.windows`, calls `Runtime.flushReady()`, and binds window methods here. This is always available, even in markup-only apps.

- **`"stigma"` module** (lazy) — The full JSX runtime including `createElement`, hooks (`useState`, `useEffect`), the virtual DOM reconciler, and the `render` function. This module is only loaded when user code writes `import ... from "stigma"`. Markup-only apps that never import it pay zero cost for the reconciler.

### The Promise Bridge

Sunflower's async system bridges Crystal fibers and JavaScript promises:

1. A JS call (e.g. `img.setResourcePath(url)`) invokes a Crystal binding
2. Crystal generates a unique promise ID and spawns a fiber for the async work
3. The binding returns the promise ID to JS, which wraps it in a `Promise`
4. The Crystal fiber completes the work (HTTP request, file I/O, etc.)
5. It calls `resolve_promise(id, value)` to queue the result
6. A GLib timer (running at ~60fps) picks up resolved promises, passes values to JS, and drains the QuickJS job queue

This gives you true non-blocking async in JS while all heavy lifting happens in Crystal fibers — no thread pools, no callback hell, and the GTK main loop never blocks.

### The Module Loader

Sunflower uses a custom ES module loader that integrates with QuickJS's native `import`/`export` system. When you write `import { useState } from "stigma"`, QuickJS calls into a C++ bridge that checks Sunflower's built-in module registry first. If the module isn't registered, it falls back to loading `.js` files from disk with path resolution relative to the importing file.

Built-in modules register their JavaScript source at startup. The source uses standard ES module syntax (`export class`, `export function`) and calls into native Crystal bindings under the hood.

### The Job Drain

A GLib timer fires every 16ms to:

1. Yield to Crystal's fiber scheduler (so spawned fibers can run)
2. Flush any resolved promises into JavaScript
3. Drain the QuickJS job queue (so `await` continuations execute)

This is the heartbeat that keeps async flowing between Crystal and JS without blocking the UI.

### The JSX Transpiler

When a `.jsx` file is loaded, Sunflower's built-in transpiler converts JSX syntax to `createElement()` calls before passing the code to QuickJS. No external build tools needed.

```jsx
// Input
<Box orientation="vertical">
  <Label className="title">Hello</Label>
</Box>

// Output
Stigma.createElement("Box", { orientation: "vertical" },
  Stigma.createElement("Label", { className: "title" }, "Hello")
)
```

The transpiled `Stigma.createElement` references resolve through the user's `import Stigma from "stigma"` — the transpiler only handles syntax transformation, not imports.

Custom components (uppercase names not matching built-in widgets) are emitted as function references: `<MyComponent />` becomes `Stigma.createElement(MyComponent, null)`.

Fragment shorthand `<>...</>` is transpiled to `Stigma.createElement(Fragment, null, ...)`.

### The Reconciler

In JSX mode, the Stigma module includes a virtual DOM reconciler that diffs old and new component trees. When state changes:

1. The component function re-runs, producing a new virtual DOM tree
2. The reconciler walks old and new trees side by side
3. Same element type → updates the existing GTK widget in-place (properties, text, event handlers)
4. Different type → destroys the old widget and creates a new one
5. Entry widgets are never overwritten during updates to preserve user input

This means `useState` triggers efficient in-place updates — not a full tear-down and rebuild.

### The 2D Renderer

The Canvas module uses a batched OpenGL renderer on top of GTK4's `GLArea`. Each frame, JavaScript draw commands are collected into a command buffer (clear, fillRect, fillCircle, etc.). When the GLArea renders, the Crystal side walks the command buffer and pushes vertices into a single VBO, drawing everything in one or a few `glDrawArrays` calls.

The renderer uses an orthographic projection with `(0,0)` at the top-left corner. HiDPI displays are handled automatically — the viewport scales to physical pixels while the projection stays in logical coordinates, so game code doesn't need to know about Retina scaling.

## Contributing

1. Fork it (<https://github.com/grkek/sunflower/fork>)
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create a new Pull Request

## Contributors

- [Giorgi Kavrelishvili](https://github.com/grkek) - creator and maintainer