sunflower

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

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 and uses QuickJS (via 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 (>= 1.10)
  • GTK4 development libraries
  • GLib development libraries

Installation

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:

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:

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:

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.

<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>
// 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:

<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:

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:

<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:

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:

<!-- 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:

<!-- 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:

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:

// 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:

// 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:

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

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

Label

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

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

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

Image

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

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

ListBox

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

Window

import { Window } from "stigma";

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

Universal Methods

Available on all components:

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:

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

Lifecycle

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:

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:

<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:

import Stigma from "stigma";

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

useState

Manage component state with useState:

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:

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:

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

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

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

Fragments

Group elements without adding a wrapper widget:

// 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:

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 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.

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

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

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

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.

Error Handling

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);
}

Console

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"}

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

{
  "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

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.

// 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

Repository

sunflower

Owner
Statistic
  • 8
  • 0
  • 0
  • 0
  • 4
  • 18 days ago
  • March 15, 2026
License

MIT License

Links
Synced at

Fri, 27 Mar 2026 07:08:52 GMT

Languages