Prototype Python GUI library powered by a from-scratch Rust renderer
Project description
⚡ fxl-ui ⚡
Available in:
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 developbefore 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
optionslist 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_bmponly reads uncompressed (BI_RGB) 24- or 32-bit BMP files.image_from_filecovers PNG and JPEG (any color type/bit depth theimagecrate supports, decoded via itspng/jpegcodecs). For any other format (GIF, WebP, TIFF, ...), decode it with Pillow/numpy and useimage()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_tickcallback (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 parentcolumn()/row()'s main axis — aColumn's children still stretch to its full width and aRow's to its full height regardless ofgrow, same as before flex-grow existed. It's also opt-in per widget (everything defaults togrow=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 ascroll_view()(or atext_area(), for its own text) — so continue to size windows generously for non-growing content, asexamples/demo.pydoes.
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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3cccbaa26f43e5ad4345edf4b0d17f50e09f743420813627dfbfc9b469dd2a54
|
|
| MD5 |
e11b558c8d58adcb9feff32163c9e42f
|
|
| BLAKE2b-256 |
6f962b6e124e04ba6fea61764970e0f0aef93f11c996063dd0a838e7d5903e3b
|