Skip to main content

Prototype Python GUI library powered by a from-scratch Rust renderer

Project description

⚡ fxl-ui ⚡

Available in:

Rust    Python    Go

fxl is a prototype Python GUI library whose entire rendering stack is built from scratch in Rust — no Qt, Tk, winit, egui, or other GUI frameworks.

Architecture

Python API (fxl.Window, widgets, layouts)
        │
        ▼
PyO3 bindings (_core.App)
        │
        ├── Widget tree + flex layout engine
        ├── CPU software renderer (Canvas)
        │     ├── anti-aliased fill/stroke: rect, round-rect, circle, ring/arc, triangle, polygon
        │     ├── horizontal & sweep colour-gradient fills, and gradient text
        │     ├── bilinear-filtered image blit
        │     └── GDI ClearType text by default (Segoe UI), embedded 8×8 bitmap font as fallback
        └── Platform layer (raw Win32 on Windows)
              ├── window + message pump, Per-Monitor-V2 DPI awareness
              ├── mouse capture (drag), TrackMouseEvent (hover leave)
              ├── WM_CHAR / WM_KEYDOWN text input
              └── StretchDIBits framebuffer blit

(PNG/JPEG decoding is the one exception to "built from scratch": image_from_file delegates just those two codecs to the image crate — see Why from scratch? below. Hand-written BMP decoding and everything else, including the renderer, stays dependency-free.)

Everything above the OS window API is ours: layout, hit-testing, painting, text, animation, and the event model.

Quick start

# Install maturin if needed
pip install maturin

# Build and install in editable mode
maturin develop

# Run the smallest example
cd examples
python hello.py

# Or run the feature showcase
python demo.py

If you previously built this project, rebuild with maturin develop before running the demo — the new widgets below don't exist in an old binary.

Minimal first app

import fxl

window = fxl.Window("Hello fxl", width=420, height=220)
label = window.label("Hello from fxl")
button = window.button("Click me", on_click=lambda: window.set_label_text(label, "You clicked the button!"))
root = window.column(label, window.spacer(height=12), button)
window.set_content(root)
window.run()

Python example

import fxl
from fxl import fonts

window = fxl.Window("Hello fxl", width=480, height=320)

status = window.label("Ready")
volume = window.progress_bar(value=0.4, show_label=True)
health = window.circular_progress(value=0.85, size=100, show_label=True,
                                   gradient_start=(34, 197, 94), gradient_end=(16, 185, 129))

def on_click():
    window.set_label_text(status, "Button clicked!")

def on_slide(value):
    window.set_progress(volume, value / 100.0)

root = window.column(
    window.gradient_text("fxl prototype", start=(255, 90, 90), end=(90, 130, 255)),
    window.row(volume, window.spacer(width=16), health),
    window.button("Click me", on_click=on_click),
    status,
    window.slider(min=0, max=100, value=40, on_change=on_slide),
)

window.set_content(root)
window.run()

See examples/demo.py for a complete tour of every widget below, laid out as a live-updating system dashboard, and examples/new_widgets.py for a focused look at the scroll view / dropdown / text area trio added most recently.

Loading a PNG or JPEG

import fxl

window = fxl.Window("Image demo", width=360, height=300)
photo = window.image_from_file("photo.jpg", display_width=320, display_height=240)
root = window.column(photo)
window.set_content(root)
window.run()

image_from_file sniffs the format from the file's magic bytes, so a .jpg that's actually a PNG (or vice versa) still loads correctly.

Scroll view / dropdown / text area example

import fxl

window = fxl.Window("New widgets", width=420, height=420)

# A vertical scroll panel: more rows than fit in its 160px viewport.
rows = [window.label(f"Row {i}") for i in range(20)]
panel = window.scroll_view(*rows, width=360, height=160, bg=(255, 255, 255))

# A dropdown / combo box.
size = window.dropdown(["Small", "Medium", "Large"], selected=1,
                        on_change=lambda text: window.set_label_text(status, f"Size: {text}"))
status = window.label("Size: Medium")

# A multi-line, scrollable text area.
notes = window.text_area(placeholder="Notes...", width=360, height=120)

root = window.column(panel, size, status, notes)
window.set_content(root)
window.run()

Widgets

# Widget Constructor Notes
1 Gradient / coloured text gradient_text(text, start, end, font=None), or label(text, color=(r,g,b)) for a flat colour start/end are (r, g, b) tuples; the text sweeps left-to-right between them
2 Input box input(placeholder="", value="", numeric=False, max_length=0, width=220, on_change=None, on_submit=None, font=None) get_text() / set_text() read or pre-fill the value; on_submit fires on Enter. Supports text selection (click-drag, Shift+click, Shift+arrows, Ctrl+A) and copying/cutting/pasting it with Ctrl+C/Ctrl+X/Ctrl+V
3 Progress / loading bar progress_bar(value=0.0, width=240, height=18, show_label=False, gradient_start=None, gradient_end=None) value is 0.0–1.0; set_progress() / get_progress() to drive it; pair with on_tick to animate without blocking
3b Circular progress ring circular_progress(value=0.0, size=120, thickness=12, show_label=False, gradient_start=None, gradient_end=None, font=None) The round counterpart to the bar above — size is the outer diameter, thickness the ring's stroke width. Drawn with rounded end-caps starting at 12 o'clock and sweeping clockwise; shares set_progress() / get_progress() with the linear bar
4 Checkbox checkbox(text="", checked=False, on_change=None, font=None) on_change(checked: bool); is_checked() / set_checked()
5 Slider slider(min=0.0, max=100.0, value=50.0, step=0.0, width=220, show_label=False, on_change=None) on_change(value: float); drag the thumb or click the track; get_value() / set_slider_value()
6 Toggle switch toggle(text="", checked=False, on_change=None, font=None) Same get/set API as Checkbox; the knob animates with an eased slide
7 Image image(data, width, height, display_width=None, display_height=None), image_from_bmp(path, ...), or image_from_file(path, ...) data is raw RGBA8 bytes (width*height*4); for any other format, decode with Pillow/numpy and pass img.convert("RGBA").tobytes(). image_from_bmp reads uncompressed 24/32-bit BMP files with a small built-in decoder (no third-party crate). image_from_file reads PNG or JPEG files directly, detecting the format from magic bytes rather than the file extension
8 Shapes circle(size=48, color=..., outline=None, outline_width=0), triangle(width, height, color, ...), rectangle(width, height, radius=0, color, ...), polygon(points, width, height, color, ...) polygon() takes any list of (x, y) points normalised to the shape's own 0..1 bounding box — stars, pentagons, arrows, anything
9 Tooltip set_tooltip(handle, text) Attaches to any widget handle (button, image, shape, slider, ...); appears after a short hover delay
10 Scroll view scroll_view(*children, width=320, height=220, bg=None, radius=12) A fixed-size viewport onto a taller stack of children — the scrollable counterpart to column(). Scrolls via mouse wheel or by dragging the scrollbar that appears automatically once content overflows; get_scroll_offset() / set_scroll_offset() to read or drive it programmatically
11 Dropdown / combo box dropdown(options, selected=None, placeholder="Select...", width=220, on_change=None, font=None) Click the closed box to open a floating option list; pick with a click, or Up/Down + Enter once open (Escape cancels). on_change(text: str) fires on selection; get_selected_index() / get_selected_text() / set_selected_index() to read or drive it
12 Multi-line text area text_area(placeholder="", value="", max_length=0, width=320, height=160, on_change=None, font=None) The richer, scrollable counterpart to input() for forms, logs, and editable text blocks. Enter inserts a newline; text word-wraps to fit the box's width, and Left/Right/Up/Down/Home/End/Backspace/Delete all navigate the visual (wrapped) lines as expected, auto-scrolling to keep the caret in view. Supports click-drag/Shift+arrow/Ctrl+A selection and copying/cutting/pasting with Ctrl+C/Ctrl+X/Ctrl+V, same as input(). Shares get_text() / set_text() with input()

Bonus: on_tick(callback) registers a callback fired once per frame with the elapsed seconds since the last frame — handy for animating a progress bar/ring or anything else without blocking the event loop with time.sleep().

Bonus: column(*children, bg=None, radius=12) and row(*children, bg=None, radius=12) optionally paint a rounded panel behind their contents — pass bg=(r, g, b) to turn a group of widgets into a "card" (as used throughout examples/demo.py).

Bonus: set_flex(handle, grow=1) lets a widget stretch to soak up its parent column()/row()'s leftover main-axis space, the same model as CSS's flex-grow. Every widget defaults to grow=0 (sized to its own content); give one child grow=1 in a row() next to fixed-width siblings and it fills whatever width they leave unused, or split the leftover unevenly by giving siblings different weights (e.g. 1 and 2 splits it 1:2).

All of the above are available both as App methods (window.app.progress_bar(...)) and as identically-named methods on the high-level Window wrapper used in the examples.

Current scope (v0.3 prototype)

Feature Status
Window (Win32)
Column / Row layout (optional card background + rounded corners)
Label, Button, Spacer
Gradient / coloured text
Input box (text + numeric)
Progress / loading bar (linear)
Circular / ring progress
Checkbox
Slider
Toggle switch
Images (raw RGBA bytes / BMP / PNG / JPEG)
Shapes (circle, triangle, rectangle, polygon)
Tooltips
Scroll view (mouse wheel + draggable scrollbar)
Dropdown / combo box (click or keyboard driven)
Multi-line text area (scrollable, caret navigation)
Per-frame animation (on_tick, eased toggle knob)
Software renderer ✅ (anti-aliased: circles, rounded corners, triangles, polygons, ring/arc strokes, line strokes; bilinear-filtered images)
Bitmap font (ASCII) + named GDI fonts ✅ (GDI/ClearType is now the default for all text; bitmap font is the fallback)
Per-Monitor-V2 DPI awareness
Python callbacks
macOS / Linux 🔲 (stub platform — compiles, doesn't render)
Text selection (click-drag, Shift+click, Shift+arrows, Ctrl+A) + Ctrl+C copy
Paste (Ctrl+V), cut (Ctrl+X)
Word-wrap in the text area ✅ (still vertical-only scrolling — no horizontal scroll/pan)
Flex-grow layout (set_flex())
Image formats beyond raw RGBA / BMP / PNG / JPEG 🔲 (decode elsewhere, e.g. with Pillow, and pass raw RGBA bytes to image())

Known limitations

  • The dropdown's option list isn't itself scrollable; a very long options list is shown in full, which can run off the bottom of the window on a short one.
  • The text area's word-wrap breaks at whitespace only; a single word wider than the box (a long URL, say) is placed on its own line rather than split mid-word, so it can still overflow horizontally in that one case. There's still no horizontal scrolling to pan into it.
  • image_from_bmp only reads uncompressed (BI_RGB) 24- or 32-bit BMP files. image_from_file covers PNG and JPEG (any color type/bit depth the image crate supports, decoded via its png/jpeg codecs). For any other format (GIF, WebP, TIFF, ...), decode it with Pillow/numpy and use image() with raw RGBA bytes instead.
  • Tooltip/hover state is driven by WM_MOUSEMOVE/TrackMouseEvent; if the cursor leaves the screen entirely at a corner rather than crossing the window's edge, Windows can occasionally skip the final move event — this is a known Win32 quirk, not specific to fxl.
  • The event loop still polls for input at ~60 Hz, but only presents (re-layouts, repaints, and blits the frame) when something actually needs it: an OS event arrived, a Toggle's knob is still easing, a widget has keyboard focus (for the blinking caret), a tooltip's hover-delay is pending, or the app has registered an on_tick callback (which opts into presenting every frame, since Rust can't tell whether the callback body changed anything visual). A window with nothing focused, hovered, animating, or ticking is idle in the fuller sense now — no full repaint runs until one of those becomes true again.
  • Declaring DPI awareness stops Windows from auto-stretching the window's output on a scaled display (125%/150%, the default on most modern laptops), trading "blurry but big" for "crisp at its true pixel size." The window itself isn't yet rescaled to compensate, so on a scaled display it may now appear physically smaller on screen than it used to (just sharp). Full per-monitor scaling of layout/fonts to compensate is possible as a follow-up if that's noticeable for your setup.
  • Bilinear image filtering smooths any upscale, including high-contrast synthetic patterns like checkerboards (the demo's placeholder image), where it can look softer rather than crisper — this is expected; photos, icons, and most real images look noticeably better with it.
  • set_flex() only grows a widget along its parent column()/row()'s main axis — a Column's children still stretch to its full width and a Row's to its full height regardless of grow, same as before flex-grow existed. It's also opt-in per widget (everything defaults to grow=0, sized to its own content) and doesn't add scrolling: content still too tall/wide even after every growing child has expanded is clipped rather than scrolled, unless the container is a scroll_view() (or a text_area(), for its own text) — so continue to size windows generously for non-growing content, as examples/demo.py does.

Why from scratch?

Third-party GUI libraries bring their own layout models, styling systems, and rendering pipelines. Building the stack ourselves keeps full control over:

  • Custom rendering (shaders, effects, non-rectangular UI)
  • Python ↔ Rust widget lifecycle
  • Performance-critical paint paths
  • Novel layout paradigms

The one deliberate exception is PNG/JPEG decoding: image_from_file uses the image crate (scoped to just its png/jpeg codecs, default-features = false) rather than a hand-written decoder, since a correct implementation of either format — DEFLATE + PNG filtering, or DCT + Huffman + chroma subsampling for JPEG — is a substantial project on its own with a real risk of subtle bugs. Everything downstream of decoding (the widget, layout, and rendering) is still fxl's own code; BMP decoding also remains fully hand-written in bmp.rs.

Testing

The core widget tree, layout, and rendering logic has a Rust unit test suite that runs independently of Windows or Python (it exercises WidgetTree/Canvas directly):

cargo test

This is how the new widgets in this release were validated without a Windows machine on hand — the Win32-specific input plumbing (src/platform/win32.rs) is the one piece that can only be exercised on an actual Windows build.

License

MIT (prototype — use at your own risk)

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fxl-0.1.0-cp312-cp312-win_amd64.whl (5.7 MB view details)

Uploaded CPython 3.12Windows x86-64

File details

Details for the file fxl-0.1.0-cp312-cp312-win_amd64.whl.

File metadata

  • Download URL: fxl-0.1.0-cp312-cp312-win_amd64.whl
  • Upload date:
  • Size: 5.7 MB
  • Tags: CPython 3.12, Windows x86-64
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.10

File hashes

Hashes for fxl-0.1.0-cp312-cp312-win_amd64.whl
Algorithm Hash digest
SHA256 3cccbaa26f43e5ad4345edf4b0d17f50e09f743420813627dfbfc9b469dd2a54
MD5 e11b558c8d58adcb9feff32163c9e42f
BLAKE2b-256 6f962b6e124e04ba6fea61764970e0f0aef93f11c996063dd0a838e7d5903e3b

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page