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 and uses QuickJS (via Medusa) as its embedded JavaScript runtime.
| 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.
- Crystal (>= 1.10)
- GTK4 development libraries
- GLib development libraries
git clone https://github.com/grkek/sunflower.git
cd sunflower
shards install
./bin/gi-crystalCreate a project with the following structure:
my-application/
└── dist/
└── index.html
└── scripts/
└── App.jsx
└── src/
└── application.cr
application.cr — your entry point:
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:
<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:
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>
);
}
$.onReady(function() {
$.render("root", App);
});Run it:
GTK_DEBUG=interactive crystal run ./src/application.cr -Dpreview_mtSunflower supports two development styles:
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.
<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="btn">Click</Button>
</Box>
</Window>
<Script src="scripts/index.js" />
</Application>Define your UI as composable function components with useState, useEffect, and a virtual DOM reconciler. The markup becomes a minimal shell.
src/index.html:
<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:
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>
);
}
$.onReady(function() {
$.render("root", Counter);
});The JSX transpiler runs automatically for .jsx files — no build step required.
┌─────────────────────────────────────┐
│ 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.
Sunflower uses an XML-based markup language. Every application starts with an <Application> root containing a <Window>.
| 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="..." />.
Every component supports:
id— Unique identifier for JS accessclassName— CSS class for stylingexpand— Whether the widget expands to fill available spacehorizontalAlignment—"center","start","end","fill"verticalAlignment—"center","start","end","fill"
Embed JavaScript inline or load from a file:
<!-- Inline -->
<Script>
console.log("Hello from Sunflower!");
</Script>
<!-- External JS -->
<Script src="scripts/index.js" />
<!-- External JSX (auto-transpiled) -->
<Script src="scripts/App.jsx" />Style your application with GTK CSS:
<!-- Inline -->
<StyleSheet>
.my-button {
background-color: #3584e4;
color: white;
border-radius: 6px;
padding: 8px 16px;
}
</StyleSheet>
<!-- External -->
<StyleSheet src="styles/index.css" />Sunflower's standard library modules are available as ES module imports:
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.
The global $ object is your entry point to the application.
// Access the main window
$.mainWindow;
// Get a component by ID
var btn = $.getComponentById("myButton");
// Get a component from a specific window
var label = $.getComponentById("title", "Main");
// List all component IDs
console.log($.componentIds);
// List all window IDs
console.log($.windowIds);Attach handlers through the on property:
$.getComponentById("myButton").on.press = function() {
console.log("Button pressed!");
};
$.getComponentById("myEntry").on.change = function(text) {
console.log("Text changed: " + text);
};var btn = $.getComponentById("myButton");
btn.setText("New Label");var label = $.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);var entry = $.getComponentById("myEntry");
entry.setText("Default value");
var text = entry.getText();
entry.isPassword(true);var img = $.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"var box = $.getComponentById("myBox");
box.append("childComponentId");
box.destroyChildren();var list = $.getComponentById("myList");
list.removeAll();var win = $.mainWindow;
win.setTitle("New Title");
win.maximize();
win.minimize();Available on all components:
var comp = $.getComponentById("any");
comp.setVisible(false);
comp.addCssClass("highlighted");
comp.removeCssClass("highlighted");Every component has a lazy state getter that reads the current widget state from GTK:
var btn = $.getComponentById("myButton");
console.log(btn.state);// Run code when the application is ready (all components mounted)
$.onReady(function() {
console.log("I am ready!");
});
// Run code on exit (supports multiple callbacks)
$.onExit(function() {
console.log("Goodbye!");
});Sunflower has full async/await support. Any Crystal binding that does I/O returns a JS Promise that you can await:
$.onReady(async function() {
await img.setResourcePath("https://example.com/photo.jpg");
console.log("Image loaded!");
});Create a minimal HTML shell with a root container, then write your UI in .jsx files:
<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>Components are plain functions that return JSX:
function Greeting({ name }) {
return (
<Box orientation="vertical">
<Label className="title">Hello, {name}!</Label>
</Box>
);
}Manage component state with useState:
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>
);
}Run side effects after render:
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>;
}Nest components and pass props:
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>
);
}
$.onReady(function() {
$.render("root", App);
});function App() {
const [loggedIn, setLoggedIn] = useState(false);
if (loggedIn) {
return <Label>Welcome back!</Label>;
}
return (
<Button onPress={function() { setLoggedIn(true); }}>
Sign In
</Button>
);
}<Button onPress={handleClick}>Click Me</Button>
<Entry onChange={function(text) { setQuery(text); }} />Mount your root component into a container defined in the HTML:
$.onReady(function() {
$.render("root", App);
});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.
Add a <Canvas> element to your JSX layout and import the Canvas class:
import { Canvas } from "canvas";
function MyGame() {
useEffect(function() {
const canvas = new Canvas("game", {
width: 800,
height: 600,
framesPerSecond: 60
});
canvas.onDraw(function(context) {
context.clear("#000000");
context.fillRect(100, 100, 50, 50, "#ff0000");
});
canvas.start();
}, []);
return (
<Box orientation="vertical" expand="true">
<Canvas id="game" expand="true" />
</Box>
);
}const canvas = new Canvas(id, options);| Option | Type | Default | Description |
|---|---|---|---|
width |
number | 800 | Requested width in logical pixels |
height |
number | 600 | Requested height in logical pixels |
framesPerSecond |
number | 60 | Target frame rate |
The actual canvas size may differ from the requested size when expand="true" is set — use canvas.getWidth() and canvas.getHeight() to read the real dimensions.
The canvas runs two callbacks per frame at the configured frame rate:
// Called before drawing — update game state here
canvas.onUpdate(function(dt) {
// dt is the time since last frame in seconds
player.x += player.speed * dt;
});
// Called after update — draw your frame here
canvas.onDraw(function(context) {
context.clear("#000000");
context.fillRect(player.x, player.y, 32, 32, "#00ff00");
});
// Start the game loop
canvas.start();
// Stop the game loop
canvas.stop();The context object passed to onDraw provides these drawing primitives:
canvas.onDraw(function(context) {
// Clear the entire canvas
context.clear("#000000");
// Filled rectangle
context.fillRect(x, y, width, height, color);
// Stroked rectangle (outline only)
context.strokeRect(x, y, width, height, color, lineWidth);
// Filled circle
context.fillCircle(centerX, centerY, radius, color);
// Stroked circle (outline only)
context.strokeCircle(centerX, centerY, radius, color, lineWidth);
// Line between two points
context.drawLine(x1, y1, x2, y2, color, lineWidth);
// Filled triangle
context.fillTriangle(x1, y1, x2, y2, x3, y3, color);
// Text (placeholder — renders as a rectangle until font atlas is implemented)
context.fillText(text, x, y, color, fontSize);
});All colors are hex strings with optional alpha: "#ff0000", "#ff000080" (50% transparent red).
// Keyboard — poll in onUpdate
canvas.onUpdate(function(dt) {
if (canvas.isKeyDown("w")) player.y -= speed;
if (canvas.isKeyDown("s")) player.y += speed;
if (canvas.isKeyDown("Left")) player.x -= speed;
if (canvas.isKeyDown("Right")) player.x += speed;
});
// Keyboard — event callbacks
canvas.onKeyDown(function(key) {
console.log("Pressed: " + key);
});
canvas.onKeyUp(function(key) {
console.log("Released: " + key);
});
// Mouse — poll in onUpdate
canvas.onUpdate(function(dt) {
var mx = canvas.mouseX();
var my = canvas.mouseY();
var pressed = canvas.isMouseDown();
});
// Mouse — event callbacks
canvas.onMouseDown(function(x, y) { });
canvas.onMouseUp(function(x, y) { });
canvas.onMouseMove(function(x, y) { });Key names follow GDK naming: "w", "s", "Up", "Down", "Left", "Right", "space", "Return", etc.
The canvas automatically adapts to HiDPI displays (Retina). Use getWidth() and getHeight() to read the actual logical dimensions and make your game responsive to window resizing:
canvas.onUpdate(function(dt) {
var W = canvas.getWidth();
var H = canvas.getHeight();
// Clamp player to canvas bounds
player.x = Math.max(0, Math.min(W - player.size, player.x));
player.y = Math.max(0, Math.min(H - player.size, player.y));
});
canvas.onDraw(function(context) {
var W = canvas.getWidth();
var H = canvas.getHeight();
context.clear("#000");
// Center line
context.drawLine(W / 2, 0, W / 2, H, "#333", 2);
});Sunflower's standard library is available as ES module imports.
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");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);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 section for the full API reference.
All async module calls return an error field on failure instead of throwing:
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);
}The standard library modules are also available on the global $ object for backward compatibility:
// These still work
await $.fs.read("/path/to/file.txt");
await $.http.get("https://example.com");Standard console methods are available:
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"}Sunflower exposes a UNIX socket for inter-process communication. External processes can send JSON messages to evaluate JavaScript in the running application.
{
"id": "unique-request-id",
"directory": "/path/to/project",
"file": "src/index.html",
"line": 1,
"sourceCode": "$.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 |
echo '{"id":"1","directory":".","file":"repl","line":1,"sourceCode":"console.log($.componentIds)"}' \
| socat - UNIX-CONNECT:/tmp/<socket-id>.sockThe socket path is logged on startup.
Sunflower's async system bridges Crystal fibers and JavaScript promises:
- A JS call (e.g.
img.setResourcePath(url)) invokes a Crystal binding - Crystal generates a unique promise ID and spawns a fiber for the async work
- The binding returns the promise ID to JS, which wraps it in a
Promise - The Crystal fiber completes the work (HTTP request, file I/O, etc.)
- It calls
resolve_promise(id, value)to queue the result - 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.
Sunflower uses a custom ES module loader that integrates with QuickJS's native import/export system. When you write import { Canvas } from "canvas", 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.
A GLib timer fires every 16ms to:
- Yield to Crystal's fiber scheduler (so spawned fibers can run)
- Flush any resolved promises into JavaScript
- Drain the QuickJS job queue (so
awaitcontinuations execute)
This is the heartbeat that keeps async flowing between Crystal and JS without blocking the UI.
When a .jsx file is loaded, Sunflower's built-in transpiler converts JSX syntax to h() function calls before passing the code to QuickJS. No external build tools needed.
// Input
<Box orientation="vertical">
<Label className="title">Hello</Label>
</Box>
// Output
h("Box", { orientation: "vertical" },
h("Label", { className: "title" }, "Hello")
)Custom components (uppercase names not matching built-in widgets) are emitted as function references: <MyComponent /> becomes h(MyComponent, null).
In JSX mode, the Seed runtime includes a virtual DOM reconciler that diffs old and new component trees. When state changes:
- The component function re-runs, producing a new virtual DOM tree
- The reconciler walks old and new trees side by side
- Same element type → updates the existing GTK widget in-place (props, text, event handlers)
- Different type → destroys the old widget and creates a new one
- 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 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.
- Fork it (https://github.com/grkek/sunflower/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
- Giorgi Kavrelishvili - creator and maintainer