Metadata-Version: 2.4
Name: fxl
Version: 0.1.0
Classifier: Development Status :: 3 - Alpha
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Rust
Summary: Prototype Python GUI library powered by a from-scratch Rust renderer
Requires-Python: >=3.9
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM

# ⚡ fxl-ui ⚡

**Available in:**

<img src="https://img.shields.io/badge/Rust-orange.svg?style=for-the-badge&logo=rust" alt="Rust" />&nbsp;&nbsp;&nbsp;&nbsp;<img src="https://img.shields.io/badge/Python-blue.svg?style=for-the-badge&logo=python" alt="Python" />&nbsp;&nbsp;&nbsp;&nbsp;<img src="https://img.shields.io/badge/Go-00ADD8.svg?style=for-the-badge&logo=go&logoColor=white" alt="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?](#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

```bash
# 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

```python
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

```python
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

```python
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

```python
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):

```bash
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)

