Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Welcome to Horizon Lattice, a Rust-native GUI framework inspired by Qt6’s comprehensive design philosophy.

What is Horizon Lattice?

Horizon Lattice is a cross-platform GUI toolkit built from the ground up in Rust. It takes Qt’s proven concepts—signals/slots, declarative UI, comprehensive widget set, cross-platform support—and implements them idiomatically using Rust’s ownership model and safety guarantees.

Why Horizon Lattice?

Pure Rust, No C++ Dependencies

Unlike Qt bindings, Horizon Lattice is written entirely in Rust. This means:

  • No external MOC tool required
  • Compile-time type checking for signals and slots
  • Memory safety guaranteed by the Rust compiler
  • Easy integration with the Rust ecosystem

Qt-Inspired, Rust-Idiomatic

We’ve adopted Qt’s battle-tested design patterns while making them feel natural in Rust:

FeatureQtHorizon Lattice
Code generationExternal MOC toolRust proc-macros
Signal type safetyRuntimeCompile-time
Memory managementManual + parent-childRust ownership
LicenseLGPL/CommercialMIT/Apache 2.0

Modern Graphics

Horizon Lattice uses modern graphics APIs through wgpu:

  • Vulkan, Metal, DX12, and WebGPU backends
  • GPU-accelerated 2D rendering
  • Efficient damage tracking for minimal redraws

Quick Example

use horizon_lattice::prelude::*;

fn main() -> Result<(), horizon_lattice::LatticeError> {
    let app = Application::new()?;

    let mut window = Window::new("Hello, Horizon Lattice!")
        .with_size(400.0, 300.0);

    let button = PushButton::new("Click me!");
    button.clicked().connect(|_checked| {
        println!("Button clicked!");
    });

    window.set_content_widget(button.object_id());
    window.show();

    app.run()
}

Getting Help

License

Horizon Lattice is dual-licensed under MIT and Apache 2.0. You may use it under either license.

Installation

This guide covers how to add Horizon Lattice to your Rust project.

Requirements

  • Rust: 1.89 or later (Edition 2024)
  • Platform: Windows 10+, macOS 11+, or Linux with X11/Wayland

Platform-Specific Dependencies

Linux

On Linux, you’ll need development headers for graphics and windowing:

# Ubuntu/Debian
sudo apt install libxkbcommon-dev libwayland-dev

# Fedora
sudo dnf install libxkbcommon-devel wayland-devel

# Arch
sudo pacman -S libxkbcommon wayland

macOS

No additional dependencies required. Xcode Command Line Tools are recommended:

xcode-select --install

Windows

No additional dependencies required. Visual Studio Build Tools are recommended.

Adding to Your Project

Add Horizon Lattice to your Cargo.toml:

[dependencies]
horizon-lattice = "1.0"

Optional Features

Horizon Lattice provides several optional features:

[dependencies]
horizon-lattice = { version = "1.0", features = ["multimedia", "networking"] }
FeatureDescription
multimediaAudio/video playback support
networkingHTTP client, WebSocket, TCP/UDP
accessibilityScreen reader support

Verifying Installation

Create a simple test application:

// src/main.rs
use horizon_lattice::prelude::*;

fn main() -> Result<(), horizon_lattice::LatticeError> {
    let app = Application::new()?;

    let mut window = Window::new("Installation Test")
        .with_size(300.0, 200.0);
    window.show();

    app.run()
}

Run it:

cargo run

If a window appears, you’re ready to go!

Troubleshooting

“Failed to create graphics context”

This usually means the GPU drivers don’t support the required graphics API. Try:

  • Updating your GPU drivers
  • On Linux, ensure Vulkan is installed: sudo apt install mesa-vulkan-drivers

Build errors on Linux

Ensure you have all development headers installed (see Platform-Specific Dependencies above).

Next Steps

Continue to Your First Application to build something more interesting.

Your First Application

Let’s build a simple counter application to learn the basics of Horizon Lattice.

Project Setup

Create a new Rust project:

cargo new counter-app
cd counter-app

Add Horizon Lattice to Cargo.toml:

[dependencies]
horizon-lattice = "1.0"

The Counter App

Replace src/main.rs with:

use horizon_lattice::prelude::*;
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;

fn main() -> Result<(), horizon_lattice::LatticeError> {
    // Initialize the application
    let app = Application::new()?;

    // Create the main window
    let mut window = Window::new("Counter")
        .with_size(300.0, 150.0);

    // Shared counter state
    let count = Arc::new(AtomicI32::new(0));

    // Create widgets
    let label = Label::new("Count: 0");
    let label_id = label.object_id();

    let increment_btn = PushButton::new("+");
    let decrement_btn = PushButton::new("-");

    // Connect signals
    let count_inc = count.clone();
    increment_btn.clicked().connect(move |_checked| {
        let new_value = count_inc.fetch_add(1, Ordering::SeqCst) + 1;
        println!("Count: {}", new_value);
        // Note: To update the label, you would use the widget dispatcher
        // or a property binding system in a full application
    });

    let count_dec = count.clone();
    decrement_btn.clicked().connect(move |_checked| {
        let new_value = count_dec.fetch_sub(1, Ordering::SeqCst) - 1;
        println!("Count: {}", new_value);
    });

    // Create layout
    let mut layout = BoxLayout::horizontal();
    layout.set_spacing(10.0);
    layout.add_widget(decrement_btn.object_id());
    layout.add_widget(label_id);
    layout.add_widget(increment_btn.object_id());

    // Create container with layout
    let mut container = ContainerWidget::new();
    container.set_layout(LayoutKind::from(layout));

    // Set up window
    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

Understanding the Code

Application Initialization

let app = Application::new()?;

Every Horizon Lattice application starts with Application::new(). This initializes the event loop, graphics context, and platform integration. There can only be one Application per process.

Creating Windows

let mut window = Window::new("Counter")
    .with_size(300.0, 150.0);

Windows are created with a title and can be configured using the builder pattern. Set properties like size and position before calling show().

Widgets

let label = Label::new("Count: 0");
let increment_btn = PushButton::new("+");

Widgets are the building blocks of your UI. Common widgets include:

  • Label - Display text
  • PushButton - Clickable button
  • LineEdit - Single-line text input
  • ContainerWidget - Group other widgets

Object IDs

let label_id = label.object_id();

Each widget has a unique ObjectId. This is used when adding widgets to layouts or containers, and for referencing widgets elsewhere in your code.

Signals and Slots

increment_btn.clicked().connect(move |_checked| {
    // Handle click
});

Signals are the Qt-inspired way to handle events. When a button is clicked, it emits a clicked signal with a boolean indicating if the button is checked (for toggle buttons). You connect a closure (slot) to respond to it.

Layouts

let mut layout = BoxLayout::horizontal();
layout.set_spacing(10.0);
layout.add_widget(decrement_btn.object_id());
layout.add_widget(label.object_id());
layout.add_widget(increment_btn.object_id());

Layouts automatically arrange widgets. BoxLayout::horizontal() arranges them in a row. Other layouts include:

  • BoxLayout::vertical() - Vertical arrangement
  • GridLayout - Grid arrangement
  • FormLayout - Label/field pairs
  • FlowLayout - Flowing arrangement that wraps

Containers

let mut container = ContainerWidget::new();
container.set_layout(LayoutKind::from(layout));

Containers hold child widgets and can apply layouts to position them. Use LayoutKind::from() to convert a specific layout type.

Running the Event Loop

app.run()

This starts the event loop, which:

  • Processes user input (mouse, keyboard)
  • Dispatches signals
  • Repaints widgets as needed

The function blocks until all windows are closed.

Run It

cargo run

You should see a window with - and + buttons around a “Count: 0” label. Clicking the buttons will print the counter value to the console.

Next Steps

Continue to Basic Concepts to learn more about the widget system, signals, and layouts.

Basic Concepts

This page covers the fundamental concepts you’ll use throughout Horizon Lattice.

The Widget Tree

Widgets in Horizon Lattice form a tree structure. Every widget (except the root) has a parent, and can have children.

Window
└── Container
    ├── Label
    ├── Button
    └── Container
        ├── TextEdit
        └── Button

This hierarchy determines:

  • Rendering order: Parents paint before children
  • Event propagation: Events bubble up from children to parents
  • Lifetime management: When a parent is destroyed, its children are too

Widget Lifecycle

  1. Creation: Widget::new() creates the widget
  2. Configuration: Set properties, connect signals
  3. Layout: Widget is added to a layout or parent
  4. Showing: show() makes it visible
  5. Running: Widget responds to events and repaints
  6. Destruction: Widget goes out of scope or is explicitly removed

Signals and Slots

Signals are a type-safe way to connect events to handlers.

Emitting Signals

Widgets define signals for events they can produce:

// Button has a clicked signal
button.clicked().connect(|_| {
    println!("Clicked!");
});

Signal Parameters

Signals can carry data:

// TextEdit emits the new text when changed
text_edit.text_changed().connect(|new_text: &String| {
    println!("Text is now: {}", new_text);
});

Connection Types

By default, connections are automatic—direct if on the same thread, queued if cross-thread:

// Explicit connection type
button.clicked().connect_with_type(
    |_| { /* handler */ },
    ConnectionType::Queued,
);

Layouts

Layouts automatically position and size child widgets.

HBoxLayout and VBoxLayout

Arrange widgets in a row or column:

let mut hbox = HBoxLayout::new();
hbox.add_widget(button1);
hbox.add_spacing(10);
hbox.add_widget(button2);
hbox.add_stretch(1); // Pushes remaining widgets to the right
hbox.add_widget(button3);

GridLayout

Arrange widgets in a grid:

let mut grid = GridLayout::new();
grid.add_widget(widget, row, column);
grid.add_widget_with_span(wide_widget, row, column, row_span, col_span);

Size Policies

Control how widgets grow and shrink:

// Fixed size - won't grow or shrink
widget.set_size_policy(SizePolicy::Fixed, SizePolicy::Fixed);

// Expanding - actively wants more space
widget.set_size_policy(SizePolicy::Expanding, SizePolicy::Preferred);

Styling

Widgets can be styled with CSS-like syntax:

// Inline style
button.set_style("background-color: #3498db; color: white;");

// From stylesheet
app.set_stylesheet(r#"
    Button {
        background-color: #3498db;
        color: white;
        padding: 8px 16px;
        border-radius: 4px;
    }

    Button:hover {
        background-color: #2980b9;
    }
"#)?;

Coordinate Systems

Widgets use several coordinate systems:

  • Local: Origin at widget’s top-left (0, 0)
  • Parent: Relative to parent widget
  • Window: Relative to window’s top-left
  • Global: Screen coordinates

Convert between them:

let parent_pos = widget.map_to_parent(local_pos);
let window_pos = widget.map_to_window(local_pos);
let global_pos = widget.map_to_global(local_pos);

Event Handling

Widgets receive events through the event() method:

impl Widget for MyWidget {
    fn event(&mut self, event: &mut WidgetEvent) -> bool {
        match event {
            WidgetEvent::MousePress(e) => {
                println!("Clicked at {:?}", e.position());
                event.accept();
                true // Event was handled
            }
            WidgetEvent::KeyPress(e) => {
                if e.key() == Key::Enter {
                    self.submit();
                    event.accept();
                    true
                } else {
                    false // Let parent handle it
                }
            }
            _ => false,
        }
    }
}

Next Steps

Now that you understand the basics, explore the detailed guides:

Architecture Overview

This guide explains the high-level architecture of Horizon Lattice.

System Overview

Horizon Lattice is organized into several crates:

horizon-lattice          # Main crate (re-exports everything)
├── horizon-lattice-core     # Event loop, signals, properties, objects
├── horizon-lattice-render   # GPU rendering with wgpu
├── horizon-lattice-style    # CSS-like styling system
├── horizon-lattice-macros   # Procedural macros
├── horizon-lattice-multimedia  # Audio/video (optional)
└── horizon-lattice-net      # Networking (optional)

Core Components

Application and Event Loop

The Application singleton manages the main event loop. It:

  • Processes platform events (window, input)
  • Dispatches signals
  • Schedules timers and async tasks
  • Coordinates repainting

Object System

All widgets inherit from Object, providing:

  • Unique object IDs
  • Parent-child relationships
  • Dynamic properties
  • Thread affinity tracking

Widget System

The widget system provides:

  • Base Widget trait with lifecycle methods
  • WidgetBase for common functionality
  • Event dispatch and propagation
  • Focus management
  • Coordinate mapping

Rendering

The rendering system uses wgpu for GPU-accelerated 2D graphics:

  • Immediate-mode Renderer trait
  • Damage tracking for efficient updates
  • Layer compositing with blend modes
  • Text shaping and rendering

Styling

The style system provides CSS-like styling:

  • Selector matching (type, class, id, pseudo-class)
  • Property inheritance
  • Computed style caching

Threading Model

Horizon Lattice follows Qt’s threading model:

  • Main thread: All UI operations must happen here
  • Worker threads: Background computation via ThreadPool
  • Signal delivery: Cross-thread signals are queued to the main thread

Design Decisions

Why Not Trait Objects for Widgets?

We use dyn Widget trait objects for flexibility, but store widgets in a registry with Arc<Mutex<dyn Widget>>. This allows:

  • Parent-child relationships via IDs
  • Safe cross-thread signal delivery
  • Dynamic widget creation

Why wgpu?

wgpu provides:

  • Cross-platform GPU access (Vulkan, Metal, DX12, WebGPU)
  • Safe Rust API
  • Excellent performance for 2D rendering

Why Signals Instead of Callbacks?

Signals provide:

  • Type-safe connections at compile time
  • Automatic cross-thread marshalling
  • Multiple connections to a single signal
  • Clean disconnection via ConnectionId

Rendering and Graphics

This guide covers the rendering primitives and graphics operations in Horizon Lattice.

Geometry Types

Points and Sizes

The fundamental types for positioning and dimensions:

use horizon_lattice::render::{Point, Size};

// Creating points
let origin = Point::ZERO;
let p1 = Point::new(100.0, 50.0);
let p2: Point = (200.0, 100.0).into();

// Point arithmetic
let offset = Point::new(10.0, 5.0);
let moved = Point::new(p1.x + offset.x, p1.y + offset.y);

// Creating sizes
let size = Size::new(800.0, 600.0);
let empty = Size::ZERO;

// Check if empty
assert!(empty.is_empty());
assert!(!size.is_empty());

// From integer dimensions
let from_u32: Size = Size::from((1920u32, 1080u32));

Rectangles

Rectangles define regions for layout and drawing:

use horizon_lattice::render::{Rect, Point, Size};

// Create from origin and size
let rect = Rect::new(10.0, 20.0, 200.0, 100.0);

// Create from points
let from_points = Rect::from_points(
    Point::new(10.0, 20.0),
    Point::new(210.0, 120.0),
);

// Access properties
assert_eq!(rect.x, 10.0);
assert_eq!(rect.y, 20.0);
assert_eq!(rect.width, 200.0);
assert_eq!(rect.height, 100.0);

// Corner accessors
let top_left = rect.origin();
let bottom_right = rect.bottom_right();
let center = rect.center();

// Point containment
let point = Point::new(50.0, 50.0);
assert!(rect.contains(point));

// Rectangle operations
let other = Rect::new(100.0, 50.0, 200.0, 100.0);
if let Some(intersection) = rect.intersection(&other) {
    println!("Overlapping area: {:?}", intersection);
}

let bounding = rect.union(&other);

// Inflate/deflate (grow or shrink)
let padded = rect.inflate(10.0, 10.0);
let inset = rect.deflate(5.0, 5.0);

// Offset (move)
let moved = rect.offset(50.0, 25.0);

Rounded Rectangles

For drawing rectangles with rounded corners:

use horizon_lattice::render::{Rect, RoundedRect, CornerRadii};

let rect = Rect::new(0.0, 0.0, 200.0, 100.0);

// Uniform corner radius
let uniform = RoundedRect::new(rect, CornerRadii::uniform(8.0));

// Per-corner radii (top-left, top-right, bottom-right, bottom-left)
let varied = RoundedRect::with_radii(
    rect,
    10.0,  // top-left
    10.0,  // top-right
    0.0,   // bottom-right (square)
    0.0,   // bottom-left (square)
);

// Check if it's actually rounded
assert!(!uniform.is_rect());

// Access the underlying rect
let bounds = uniform.rect();

Colors

Creating Colors

Multiple ways to create colors:

use horizon_lattice::render::Color;

// From RGB (0.0-1.0 range)
let red = Color::from_rgb(1.0, 0.0, 0.0);

// From RGBA with alpha
let semi_transparent = Color::from_rgba(1.0, 0.0, 0.0, 0.5);

// From 8-bit RGB values (0-255)
let blue = Color::from_rgb8(0, 0, 255);
let green_alpha = Color::from_rgba8(0, 255, 0, 128);

// From hex string
let purple = Color::from_hex("#8B5CF6").unwrap();
let with_alpha = Color::from_hex("#8B5CF680").unwrap(); // 50% alpha

// From HSV (hue 0-360, saturation/value 0-1)
let orange = Color::from_hsv(30.0, 1.0, 1.0);

// Predefined constants
let white = Color::WHITE;
let black = Color::BLACK;
let transparent = Color::TRANSPARENT;

Color Operations

use horizon_lattice::render::Color;

let color = Color::from_rgb(0.2, 0.4, 0.8);

// Modify alpha
let faded = color.with_alpha(0.5);

// Interpolate between colors
let start = Color::RED;
let end = Color::BLUE;
let midpoint = start.lerp(&end, 0.5); // Purple-ish

// Convert to different formats
let [r, g, b, a] = color.to_array();
let (r8, g8, b8, a8) = color.to_rgba8();
let hex = color.to_hex(); // "#3366CC"

// Convert to HSV
let (h, s, v) = color.to_hsv();

Paths

Paths define shapes for filling and stroking.

Building Paths Manually

use horizon_lattice::render::{Path, Point};

let mut path = Path::new();

// Move to starting point
path.move_to(Point::new(0.0, 0.0));

// Draw lines
path.line_to(Point::new(100.0, 0.0));
path.line_to(Point::new(100.0, 100.0));
path.line_to(Point::new(0.0, 100.0));

// Close the path (connects back to start)
path.close();

// Get bounding box
let bounds = path.bounds();

Path Factory Methods

Convenient methods for common shapes:

use horizon_lattice::render::{Path, Rect, Point};

// Rectangle
let rect_path = Path::rect(Rect::new(0.0, 0.0, 100.0, 50.0));

// Rounded rectangle
let rounded = Path::rounded_rect(
    Rect::new(0.0, 0.0, 100.0, 50.0),
    8.0, // corner radius
);

// Circle (center point and radius)
let circle = Path::circle(Point::new(50.0, 50.0), 25.0);

// Ellipse
let ellipse = Path::ellipse(
    Point::new(50.0, 50.0), // center
    40.0,                    // x radius
    25.0,                    // y radius
);

// Line segment
let line = Path::line(
    Point::new(0.0, 0.0),
    Point::new(100.0, 100.0),
);

// Polygon from points
let triangle = Path::polygon(&[
    Point::new(50.0, 0.0),
    Point::new(100.0, 100.0),
    Point::new(0.0, 100.0),
]);

// Star shape
let star = Path::star(
    Point::new(50.0, 50.0), // center
    5,                       // points
    40.0,                    // outer radius
    20.0,                    // inner radius
);

Bezier Curves

use horizon_lattice::render::{Path, Point};

let mut path = Path::new();
path.move_to(Point::new(0.0, 100.0));

// Quadratic bezier (one control point)
path.quad_to(
    Point::new(50.0, 0.0),   // control point
    Point::new(100.0, 100.0), // end point
);

// Cubic bezier (two control points)
path.move_to(Point::new(0.0, 50.0));
path.cubic_to(
    Point::new(25.0, 0.0),   // control point 1
    Point::new(75.0, 100.0), // control point 2
    Point::new(100.0, 50.0), // end point
);

Transforms

2D Transforms

Transform matrices for rotating, scaling, and translating:

use horizon_lattice::render::{Transform2D, Point};

// Identity (no transformation)
let identity = Transform2D::identity();

// Translation
let translate = Transform2D::translation(100.0, 50.0);

// Scaling
let scale = Transform2D::scale(2.0, 2.0); // 2x size

// Rotation (in radians)
use std::f32::consts::PI;
let rotate = Transform2D::rotation(PI / 4.0); // 45 degrees

// Composing transforms (order matters!)
// This scales first, then rotates, then translates
let combined = Transform2D::identity()
    .then_scale(2.0, 2.0)
    .then_rotate(PI / 4.0)
    .then_translate(100.0, 50.0);

// Transform a point
let point = Point::new(10.0, 20.0);
let transformed = combined.transform_point(point);

// Inverse transform
if let Some(inverse) = combined.inverse() {
    let back = inverse.transform_point(transformed);
    // back ≈ point
}

// Rotation around a specific point
let pivot = Point::new(50.0, 50.0);
let rotate_around = Transform2D::identity()
    .then_translate(-pivot.x, -pivot.y)
    .then_rotate(PI / 2.0)
    .then_translate(pivot.x, pivot.y);

Transform Stack

For hierarchical transforms (like nested widgets):

use horizon_lattice::render::{TransformStack, Point};

let mut stack = TransformStack::new();

// Save current state
stack.save();

// Apply transforms
stack.translate(100.0, 50.0);
stack.scale(2.0, 2.0);

// Transform points
let local = Point::new(10.0, 10.0);
let world = stack.transform_point(local);

// Restore previous state
stack.restore();

// Point transforms back to original coordinate space
let restored = stack.transform_point(local);
assert_eq!(restored, local);

Painting

Solid Colors and Gradients

use horizon_lattice::render::{Paint, Color, GradientStop, Point};

// Solid color fill
let solid = Paint::solid(Color::from_rgb(0.2, 0.4, 0.8));

// Linear gradient
let linear = Paint::linear_gradient(
    Point::new(0.0, 0.0),   // start point
    Point::new(100.0, 0.0), // end point
    vec![
        GradientStop::new(0.0, Color::RED),
        GradientStop::new(0.5, Color::WHITE),
        GradientStop::new(1.0, Color::BLUE),
    ],
);

// Radial gradient
let radial = Paint::radial_gradient(
    Point::new(50.0, 50.0), // center
    50.0,                    // radius
    vec![
        GradientStop::new(0.0, Color::WHITE),
        GradientStop::new(1.0, Color::from_rgba(0.0, 0.0, 0.0, 0.0)),
    ],
);

// Radial gradient with offset focus
let spotlight = Paint::radial_gradient_with_focus(
    Point::new(50.0, 50.0), // center
    50.0,                    // radius
    Point::new(30.0, 30.0), // focus point (off-center)
    vec![
        GradientStop::new(0.0, Color::WHITE),
        GradientStop::new(1.0, Color::BLACK),
    ],
);

Strokes

Configure how paths are outlined:

use horizon_lattice::render::{Stroke, Color, LineCap, LineJoin, DashPattern};

// Basic stroke
let basic = Stroke::new(Color::BLACK, 2.0);

// With line cap style
let rounded_caps = Stroke::new(Color::BLACK, 10.0)
    .with_cap(LineCap::Round);

// Line cap options:
// - LineCap::Butt   - flat, ends at exact endpoint
// - LineCap::Round  - semicircle extending past endpoint
// - LineCap::Square - square extending past endpoint

// With line join style
let rounded_corners = Stroke::new(Color::BLACK, 4.0)
    .with_join(LineJoin::Round);

// Line join options:
// - LineJoin::Miter - sharp corners (default)
// - LineJoin::Round - rounded corners
// - LineJoin::Bevel - flat corners

// Miter limit (prevents very sharp corners from extending too far)
let limited = Stroke::new(Color::BLACK, 4.0)
    .with_join(LineJoin::Miter)
    .with_miter_limit(2.0);

// Dashed lines
let dashed = Stroke::new(Color::BLACK, 2.0)
    .with_dash(DashPattern::simple(5.0, 5.0));

// Complex dash pattern: long, gap, short, gap
let complex_dash = Stroke::new(Color::BLACK, 2.0)
    .with_dash(DashPattern::new(vec![10.0, 3.0, 3.0, 3.0], 0.0));

// Animated dash (offset shifts the pattern)
let animated = Stroke::new(Color::BLACK, 2.0)
    .with_dash(DashPattern::new(vec![5.0, 5.0], 2.5));

Blend Modes

Control how colors combine when drawing overlapping content:

use horizon_lattice::render::BlendMode;

// Standard alpha blending (default)
let normal = BlendMode::Normal;

// Darkening modes
let multiply = BlendMode::Multiply; // Darken by multiplying
let darken = BlendMode::Darken;     // Take minimum

// Lightening modes
let screen = BlendMode::Screen;   // Lighten (opposite of multiply)
let lighten = BlendMode::Lighten; // Take maximum
let add = BlendMode::Add;         // Additive (glow effects)

// Porter-Duff compositing
let source = BlendMode::Source;           // Replace destination
let dest_out = BlendMode::DestinationOut; // Cut out shape
let xor = BlendMode::Xor;                 // Either but not both

Fill Rules

Determine what’s “inside” a path with overlapping regions:

use horizon_lattice::render::FillRule;

// NonZero (default) - considers winding direction
// A point is inside if the winding number is non-zero
let non_zero = FillRule::NonZero;

// EvenOdd - creates checkerboard pattern for overlaps
// A point is inside if it crosses an odd number of edges
let even_odd = FillRule::EvenOdd;

The difference matters for paths with overlapping regions:

  • NonZero: Inner shapes are filled if they wind the same direction as outer
  • EvenOdd: Overlapping regions alternate between filled and unfilled

Images

Loading and Using Images

use horizon_lattice::render::{ImageLoader, ImageScaleMode};

// Create an image loader
let loader = ImageLoader::new();

// Load an image (async in real usage)
// let image = loader.load("path/to/image.png").await?;

// Scale modes for drawing
let mode = ImageScaleMode::Fit;        // Fit within bounds, preserve aspect
let mode = ImageScaleMode::Fill;       // Fill bounds, may crop
let mode = ImageScaleMode::Stretch;    // Stretch to fill, ignores aspect
let mode = ImageScaleMode::Tile;       // Repeat to fill
let mode = ImageScaleMode::Center;     // Center at original size
let mode = ImageScaleMode::None;       // Draw at original size from top-left

Nine-Patch Images

For scalable UI elements like buttons and panels:

use horizon_lattice::render::{NinePatch, Rect};

// Nine-patch divides an image into 9 regions:
// - 4 corners (don't scale)
// - 4 edges (scale in one direction)
// - 1 center (scales in both directions)

// Create with uniform borders
// let nine_patch = NinePatch::uniform(image, 10.0);

// Create with different border sizes
// let nine_patch = NinePatch::new(
//     image,
//     10.0,  // left border
//     10.0,  // right border
//     8.0,   // top border
//     12.0,  // bottom border
// );

// Get minimum size (sum of borders)
// let min_size = nine_patch.min_size();

// Calculate patch regions for rendering
// let dest = Rect::new(0.0, 0.0, 200.0, 60.0);
// let patches = nine_patch.calculate_patches(dest);

Box Shadows

For drop shadows and glow effects:

use horizon_lattice::render::{BoxShadow, Color, Rect};

// Basic drop shadow
let shadow = BoxShadow {
    offset_x: 2.0,
    offset_y: 4.0,
    blur_radius: 8.0,
    spread_radius: 0.0,
    color: Color::from_rgba(0.0, 0.0, 0.0, 0.3),
    inset: false,
};

// Glow effect (no offset, larger blur)
let glow = BoxShadow {
    offset_x: 0.0,
    offset_y: 0.0,
    blur_radius: 20.0,
    spread_radius: 5.0,
    color: Color::from_rgba(0.3, 0.5, 1.0, 0.6),
    inset: false,
};

// Inset shadow (inner shadow)
let inset = BoxShadow {
    offset_x: 0.0,
    offset_y: 2.0,
    blur_radius: 4.0,
    spread_radius: 0.0,
    color: Color::from_rgba(0.0, 0.0, 0.0, 0.2),
    inset: true,
};

// Calculate the bounding rect needed to render the shadow
let widget_rect = Rect::new(10.0, 10.0, 100.0, 50.0);
let shadow_bounds = shadow.bounds(widget_rect);

Text Rendering

Font Configuration

use horizon_lattice::render::text::{Font, FontBuilder, FontWeight, FontStyle};

// Simple font
let font = Font::new("Helvetica", 14.0);

// Using the builder for more options
let custom = FontBuilder::new()
    .family("Inter")
    .fallback("Helvetica")
    .fallback("Arial")
    .fallback("sans-serif")
    .size(16.0)
    .weight(FontWeight::MEDIUM)
    .style(FontStyle::Normal)
    .letter_spacing(0.5)
    .build();

// Enable OpenType features
let with_features = FontBuilder::new()
    .family("Fira Code")
    .size(14.0)
    .feature("liga", 1) // Enable ligatures
    .feature("calt", 1) // Enable contextual alternates
    .build();

Text Layout

use horizon_lattice::render::text::{
    TextLayoutOptions, HorizontalAlign, VerticalAlign, WrapMode
};

// Basic layout options
let options = TextLayoutOptions::default()
    .with_max_width(Some(300.0))
    .with_wrap_mode(WrapMode::Word);

// Alignment options
let centered = TextLayoutOptions::default()
    .with_horizontal_align(HorizontalAlign::Center)
    .with_vertical_align(VerticalAlign::Middle);

// Wrap modes:
// - WrapMode::None      - No wrapping, single line
// - WrapMode::Char      - Wrap at any character
// - WrapMode::Word      - Wrap at word boundaries
// - WrapMode::WordChar  - Try word, fall back to char

// Line spacing
let spaced = TextLayoutOptions::default()
    .with_line_height(1.5); // 150% line height

Rich Text

use horizon_lattice::render::text::{TextSpan, TextDecoration};
use horizon_lattice::render::Color;

// Create styled text spans
let spans = vec![
    TextSpan::new("Hello ")
        .with_size(16.0)
        .with_color(Color::BLACK),
    TextSpan::new("World")
        .with_size(16.0)
        .with_color(Color::BLUE)
        .with_weight(700)
        .with_decoration(TextDecoration::Underline),
    TextSpan::new("!")
        .with_size(20.0)
        .with_color(Color::RED),
];

Next Steps

Widgets Guide

This guide covers the widget system in depth.

Widget Trait

Every widget implements the Widget trait:

use horizon_lattice::widget::{Widget, WidgetBase, PaintContext};
use horizon_lattice::widget::SizeHint;
use horizon_lattice::widget::events::WidgetEvent;
use horizon_lattice_core::{Object, ObjectId};

pub trait WidgetDefinition {
    fn widget_base(&self) -> &WidgetBase;
    fn widget_base_mut(&mut self) -> &mut WidgetBase;

    fn size_hint(&self) -> SizeHint { SizeHint::default() }
    fn paint(&self, ctx: &mut PaintContext<'_>) {}
    fn event(&mut self, event: &mut WidgetEvent) -> bool { false }
}

Size Hints

Size hints tell layouts what size a widget prefers:

use horizon_lattice::widget::SizeHint;
use horizon_lattice::render::Size;

// Create a simple size hint with preferred dimensions
let hint = SizeHint::from_dimensions(100.0, 30.0);
assert_eq!(hint.preferred, Size::new(100.0, 30.0));

// Add minimum and maximum constraints
let constrained = SizeHint::from_dimensions(100.0, 30.0)
    .with_minimum_dimensions(50.0, 20.0)
    .with_maximum_dimensions(200.0, 50.0);

assert_eq!(constrained.minimum, Some(Size::new(50.0, 20.0)));
assert_eq!(constrained.maximum, Some(Size::new(200.0, 50.0)));

// Create a fixed size (cannot grow or shrink)
let fixed = SizeHint::fixed(Size::new(100.0, 100.0));
assert!(fixed.is_fixed());

Size Policies

Size policies control how widgets grow and shrink:

use horizon_lattice::widget::{SizePolicy, SizePolicyPair};

// Fixed - cannot resize
let fixed = SizePolicyPair::fixed();
assert!(!fixed.horizontal.can_grow());
assert!(!fixed.horizontal.can_shrink());

// Preferred - can grow or shrink, prefers hint size
let preferred = SizePolicyPair::preferred();
assert!(preferred.horizontal.can_grow());
assert!(preferred.horizontal.can_shrink());

// Expanding - actively wants more space
let expanding = SizePolicyPair::expanding();
assert!(expanding.horizontal.wants_to_grow());

// Custom policy with stretch factor
let stretched = SizePolicyPair::new(SizePolicy::Expanding, SizePolicy::Fixed)
    .with_horizontal_stretch(2);  // Gets 2x extra space compared to stretch=1

assert_eq!(stretched.horizontal_stretch, 2);

Widget Lifecycle

  1. new() - Create widget with WidgetBase
  2. Configure properties and connect signals
  3. Add to parent/layout
  4. show() is called (inherited from parent)
  5. paint() called when visible
  6. event() called for input
  7. Widget dropped when parent is destroyed

Creating Custom Widgets

Here’s a conceptual example of creating a custom progress bar widget:

use horizon_lattice::widget::{Widget, WidgetBase, PaintContext};
use horizon_lattice::widget::SizeHint;
use horizon_lattice::widget::events::WidgetEvent;
use horizon_lattice::render::{Color, Rect};
use horizon_lattice_core::{Object, ObjectId};

struct ProgressBar {
    base: WidgetBase,
    value: f32,      // 0.0 to 1.0
    color: Color,
}

impl ProgressBar {
    pub fn new() -> Self {
        Self {
            base: WidgetBase::new::<Self>(),
            value: 0.0,
            color: Color::from_rgb8(52, 152, 219),
        }
    }

    pub fn set_value(&mut self, value: f32) {
        self.value = value.clamp(0.0, 1.0);
        self.base.update(); // Request repaint
    }

    pub fn value(&self) -> f32 {
        self.value
    }
}

impl Object for ProgressBar {
    fn object_id(&self) -> ObjectId {
        self.base.object_id()
    }
}

impl Widget for ProgressBar {
    fn widget_base(&self) -> &WidgetBase { &self.base }
    fn widget_base_mut(&mut self) -> &mut WidgetBase { &mut self.base }

    fn size_hint(&self) -> SizeHint {
        SizeHint::from_dimensions(200.0, 20.0)
            .with_minimum_dimensions(50.0, 10.0)
    }

    fn paint(&self, ctx: &mut PaintContext<'_>) {
        let rect = ctx.rect();

        // Background
        ctx.renderer().fill_rect(rect, Color::from_rgb8(200, 200, 200));

        // Progress fill
        let fill_width = rect.width() * self.value;
        let fill_rect = Rect::new(0.0, 0.0, fill_width, rect.height());
        ctx.renderer().fill_rect(fill_rect, self.color);
    }
}

Size Hint Examples

Different widgets have different size hint patterns:

use horizon_lattice::widget::SizeHint;
use horizon_lattice::render::Size;

// Label - prefers text size, can't shrink below it
fn label_size_hint(text_width: f32, text_height: f32) -> SizeHint {
    SizeHint::from_dimensions(text_width, text_height)
        .with_minimum_dimensions(text_width, text_height)
}

// Button - has padding around content
fn button_size_hint(content_width: f32, content_height: f32) -> SizeHint {
    let padding = 16.0;
    SizeHint::from_dimensions(content_width + padding, content_height + padding)
        .with_minimum_dimensions(60.0, 30.0)
}

// Text input - can expand horizontally
fn text_input_size_hint() -> SizeHint {
    SizeHint::from_dimensions(150.0, 30.0)
        .with_minimum_dimensions(50.0, 30.0)
}

Geometry Methods

Widgets provide methods to query and set their geometry:

use horizon_lattice::render::{Point, Rect, Size};

// Simulating widget geometry operations
let geometry = Rect::new(10.0, 20.0, 100.0, 50.0);

// Position (relative to parent)
let pos = geometry.origin;
assert_eq!(pos, Point::new(10.0, 20.0));

// Size
let size = geometry.size;
assert_eq!(size, Size::new(100.0, 50.0));

// Local rect (always at origin 0,0)
let local_rect = Rect::new(0.0, 0.0, size.width, size.height);
assert_eq!(local_rect.origin, Point::new(0.0, 0.0));

// Check if a point is inside the local rect
let point = Point::new(50.0, 25.0);
assert!(local_rect.contains(point));

let outside = Point::new(150.0, 25.0);
assert!(!local_rect.contains(outside));

Coordinate Mapping

Map points between widget-local and parent coordinate systems:

use horizon_lattice::render::Point;

// Widget at position (10, 20)
let widget_pos = Point::new(10.0, 20.0);

// Point in widget-local coordinates
let local_point = Point::new(5.0, 5.0);

// Map to parent coordinates
let parent_point = Point::new(
    local_point.x + widget_pos.x,
    local_point.y + widget_pos.y,
);
assert_eq!(parent_point, Point::new(15.0, 25.0));

// Map from parent back to local
let back_to_local = Point::new(
    parent_point.x - widget_pos.x,
    parent_point.y - widget_pos.y,
);
assert_eq!(back_to_local, local_point);

Visibility and Enabled State

Control widget visibility and interaction:

// Visibility concepts
let mut visible = true;
let mut enabled = true;

// Hide a widget
visible = false;

// Disable a widget (grayed out, can't interact)
enabled = false;

// Check effective state (considering parent hierarchy)
// If parent is hidden, child is effectively hidden too
fn is_effectively_visible(self_visible: bool, parent_visible: bool) -> bool {
    self_visible && parent_visible
}

assert!(!is_effectively_visible(true, false));  // Parent hidden
assert!(!is_effectively_visible(false, true));  // Self hidden
assert!(is_effectively_visible(true, true));    // Both visible

Focus Policy

Control how widgets receive keyboard focus:

use horizon_lattice::widget::FocusPolicy;

// NoFocus - widget cannot receive focus (e.g., labels)
let no_focus = FocusPolicy::NoFocus;

// TabFocus - focus via Tab key only (e.g., read-only controls)
let tab_focus = FocusPolicy::TabFocus;

// ClickFocus - focus via mouse click only
let click_focus = FocusPolicy::ClickFocus;

// StrongFocus - focus via both Tab and click (e.g., buttons, text fields)
let strong_focus = FocusPolicy::StrongFocus;

Repaint Requests

Request widget repainting when content changes:

use horizon_lattice::render::Rect;

// Full repaint - entire widget needs redrawing
fn request_full_repaint(needs_repaint: &mut bool) {
    *needs_repaint = true;
}

// Partial repaint - only a region needs redrawing
fn request_partial_repaint(dirty_region: &mut Option<Rect>, new_dirty: Rect) {
    *dirty_region = Some(match dirty_region {
        Some(existing) => existing.union(&new_dirty),
        None => new_dirty,
    });
}

let mut dirty = None;
request_partial_repaint(&mut dirty, Rect::new(0.0, 0.0, 50.0, 50.0));
request_partial_repaint(&mut dirty, Rect::new(40.0, 40.0, 50.0, 50.0));

// Dirty region is now the union of both rects
let combined = dirty.unwrap();
assert_eq!(combined.origin.x, 0.0);
assert_eq!(combined.origin.y, 0.0);

Signals and Properties

Widgets use signals to notify of changes:

use horizon_lattice_core::{Signal, Property};

// Create signals for widget state changes
let visible_changed: Signal<bool> = Signal::new();
let geometry_changed: Signal<(f32, f32, f32, f32)> = Signal::new();

// Connect to signals
visible_changed.connect(|&visible| {
    println!("Visibility changed to: {}", visible);
});

// Emit when state changes
visible_changed.emit(false);

// Properties with automatic change notification
let value: Property<f32> = Property::new(0.0);

// Get the current value
assert_eq!(value.get(), 0.0);

// Set returns true if value changed
assert!(value.set(0.5));
assert!(!value.set(0.5)); // Same value, returns false

Built-in Widgets

See the Widget Catalog for all available widgets including:

  • Basic: Label, PushButton, CheckBox, RadioButton
  • Input: LineEdit, TextEdit, SpinBox, Slider
  • Containers: Frame, GroupBox, ScrollArea, TabWidget
  • Display: ProgressBar, StatusBar
  • Dialogs: MessageBox, FileDialog, ColorDialog

Layouts Guide

Layouts automatically arrange child widgets within a container.

Layout Algorithm

Layouts use a two-pass algorithm:

  1. Measure pass: Query each child’s size_hint() and size policy
  2. Arrange pass: Assign positions and sizes to children

Content Margins

All layouts support content margins - spacing between the layout’s content and its edges:

use horizon_lattice::widget::layout::ContentMargins;

// Create uniform margins (same on all sides)
let uniform = ContentMargins::uniform(10.0);
assert_eq!(uniform.left, 10.0);
assert_eq!(uniform.top, 10.0);
assert_eq!(uniform.right, 10.0);
assert_eq!(uniform.bottom, 10.0);

// Create symmetric margins (horizontal/vertical)
let symmetric = ContentMargins::symmetric(20.0, 10.0);
assert_eq!(symmetric.left, 20.0);
assert_eq!(symmetric.right, 20.0);
assert_eq!(symmetric.top, 10.0);
assert_eq!(symmetric.bottom, 10.0);

// Create custom margins
let custom = ContentMargins::new(5.0, 10.0, 15.0, 20.0);
assert_eq!(custom.horizontal(), 20.0); // left + right
assert_eq!(custom.vertical(), 30.0);   // top + bottom

LayoutKind Enum

The LayoutKind enum provides a unified interface for all layout types:

use horizon_lattice::widget::layout::LayoutKind;

// Create different layout types
let hbox = LayoutKind::horizontal();
let vbox = LayoutKind::vertical();
let grid = LayoutKind::grid();
let form = LayoutKind::form();
let stack = LayoutKind::stack();
let flow = LayoutKind::flow();
let anchor = LayoutKind::anchor();

// All layouts share a common interface
let mut layout = LayoutKind::vertical();
assert_eq!(layout.item_count(), 0);
assert!(layout.is_empty());

BoxLayout (HBox and VBox)

Arrange widgets horizontally or vertically:

use horizon_lattice::widget::layout::{BoxLayout, ContentMargins, Orientation};

// Create a horizontal layout
let mut hbox = BoxLayout::horizontal();
hbox.set_spacing(10.0);  // Space between widgets
hbox.set_content_margins(ContentMargins::uniform(8.0));  // Outer margins

assert_eq!(hbox.spacing(), 10.0);
assert_eq!(hbox.orientation(), Orientation::Horizontal);

// Create a vertical layout
let mut vbox = BoxLayout::vertical();
vbox.set_spacing(5.0);

assert_eq!(vbox.orientation(), Orientation::Vertical);

Adding Items to Layouts

Layouts can contain widgets, spacers, and nested layouts:

use horizon_lattice::widget::layout::{LayoutKind, LayoutItem, SpacerItem, SpacerType};
use horizon_lattice::render::Size;

let mut layout = LayoutKind::vertical();

// Add a fixed spacer (takes a specific amount of space)
let fixed_spacer = LayoutItem::Spacer(SpacerItem::fixed(Size::new(0.0, 20.0)));
layout.add_item(fixed_spacer);

// Add an expanding spacer (fills available space)
let expanding_spacer = LayoutItem::Spacer(SpacerItem::new(
    Size::ZERO,
    SpacerType::Expanding,
));
layout.add_item(expanding_spacer);

assert_eq!(layout.item_count(), 2);

GridLayout

Arrange widgets in rows and columns:

use horizon_lattice::widget::layout::GridLayout;

let mut grid = GridLayout::new();

// Set spacing between cells
grid.set_spacing(10.0);
grid.set_horizontal_spacing(15.0);  // Override horizontal only
grid.set_vertical_spacing(5.0);     // Override vertical only

// Set column stretch factors (column 1 expands more)
grid.set_column_stretch(0, 0);  // Column 0: no stretch
grid.set_column_stretch(1, 1);  // Column 1: stretch factor 1

// Set row stretch
grid.set_row_stretch(0, 0);  // Row 0: no stretch
grid.set_row_stretch(1, 2);  // Row 1: stretch factor 2

// Set minimum column width
grid.set_column_minimum_width(0, 100.0);

FormLayout

Convenient layout for label-field pairs:

use horizon_lattice::widget::layout::{FormLayout, RowWrapPolicy, FieldGrowthPolicy};

let mut form = FormLayout::new();

// Configure form behavior
form.set_row_wrap_policy(RowWrapPolicy::WrapLongRows);
form.set_field_growth_policy(FieldGrowthPolicy::ExpandingFieldsGrow);

// Set spacing
form.set_horizontal_spacing(10.0);
form.set_vertical_spacing(8.0);

// The form automatically aligns labels and fields
// Labels go in the left column, fields in the right

StackLayout

Stack widgets on top of each other (only one visible at a time):

use horizon_lattice::widget::layout::{StackLayout, StackSizeMode};

let mut stack = StackLayout::new();

// Configure how the stack calculates its size
stack.set_size_mode(StackSizeMode::CurrentWidgetSize);  // Size based on current widget
// or
stack.set_size_mode(StackSizeMode::MaximumSize);  // Size based on largest widget

// Set the current index (which widget is visible)
stack.set_current_index(0);
assert_eq!(stack.current_index(), 0);

FlowLayout

Arrange widgets in a flowing pattern (like text wrapping):

use horizon_lattice::widget::layout::FlowLayout;

let mut flow = FlowLayout::new();

// Set spacing between items
flow.set_spacing(10.0);
flow.set_horizontal_spacing(15.0);  // Between items in a row
flow.set_vertical_spacing(8.0);     // Between rows

AnchorLayout

Position widgets relative to each other or the container:

use horizon_lattice::widget::layout::{AnchorLayout, Anchor, AnchorLine, AnchorTarget};
use horizon_lattice_core::ObjectId;

let mut anchor = AnchorLayout::new();

// Anchors connect widget edges to targets
// For example: widget's left edge to parent's left edge plus margin
let left_anchor = Anchor {
    line: AnchorLine::Left,
    target: AnchorTarget::Parent,
    target_line: AnchorLine::Left,
    margin: 10.0,
};

// Center horizontally in parent
let center_anchor = Anchor {
    line: AnchorLine::HorizontalCenter,
    target: AnchorTarget::Parent,
    target_line: AnchorLine::HorizontalCenter,
    margin: 0.0,
};

Nested Layouts

Layouts can be nested for complex UIs:

use horizon_lattice::widget::layout::{LayoutKind, BoxLayout, ContentMargins};

// Main vertical layout
let mut main = LayoutKind::vertical();

// Header as a horizontal layout
let mut header = BoxLayout::horizontal();
header.set_spacing(10.0);
header.set_content_margins(ContentMargins::uniform(5.0));

// Convert to LayoutKind for nesting
let header_kind = LayoutKind::from(header);

// In a real app, you would add the header layout as an item
// to the main layout

Layout Invalidation

Layouts track when they need recalculation:

use horizon_lattice::widget::layout::LayoutKind;

let mut layout = LayoutKind::vertical();

// Check if layout needs recalculation
if layout.needs_recalculation() {
    println!("Layout needs to be recalculated");
}

// Invalidate to force recalculation
layout.invalidate();
assert!(layout.needs_recalculation());

Layout Geometry

Set and query layout geometry:

use horizon_lattice::widget::layout::LayoutKind;
use horizon_lattice::render::Rect;

let mut layout = LayoutKind::vertical();

// Set the layout's bounding rectangle
let rect = Rect::new(10.0, 20.0, 300.0, 400.0);
layout.set_geometry(rect);

// Query the geometry
let geom = layout.geometry();
assert_eq!(geom.origin.x, 10.0);
assert_eq!(geom.origin.y, 20.0);
assert_eq!(geom.size.width, 300.0);
assert_eq!(geom.size.height, 400.0);

Custom Layout Implementation

Creating a custom layout involves implementing the Layout trait and optionally using LayoutBase to handle common functionality.

Architecture Overview

Custom layouts in Horizon Lattice follow this architecture:

  1. Layout trait: Defines 21 methods for item management, size calculation, geometry, and invalidation
  2. LayoutBase: A helper struct that provides common functionality (item storage, margins, spacing, caching)
  3. LayoutItem: Enum wrapping widgets, spacers, or nested layouts
  4. Two-pass algorithm: Collection (size hints) followed by distribution (positioning)

Step-by-Step: Creating a Centered Layout

Let’s build a CenteredLayout that centers a single widget within its bounds.

Step 1: Define the Struct

use horizon_lattice::widget::layout::{Layout, LayoutBase, LayoutItem, ContentMargins};
use horizon_lattice::widget::geometry::{SizeHint, SizePolicyPair};
use horizon_lattice::widget::dispatcher::WidgetAccess;
use horizon_lattice_core::ObjectId;
use horizon_lattice_render::{Rect, Size};

/// A layout that centers its single child widget.
#[derive(Debug, Clone)]
pub struct CenteredLayout {
    /// Delegate common functionality to LayoutBase.
    base: LayoutBase,
}

impl CenteredLayout {
    /// Create a new centered layout.
    pub fn new() -> Self {
        Self {
            base: LayoutBase::new(),
        }
    }
}

impl Default for CenteredLayout {
    fn default() -> Self {
        Self::new()
    }
}

Step 2: Implement the Layout Trait

The Layout trait requires implementing methods across several categories:

Item Management - Delegate to LayoutBase:

impl Layout for CenteredLayout {
    fn add_item(&mut self, item: LayoutItem) {
        // Only allow one item for centering
        if self.base.is_empty() {
            self.base.add_item(item);
        }
    }

    fn insert_item(&mut self, index: usize, item: LayoutItem) {
        if self.base.is_empty() && index == 0 {
            self.base.insert_item(index, item);
        }
    }

    fn remove_item(&mut self, index: usize) -> Option<LayoutItem> {
        self.base.remove_item(index)
    }

    fn remove_widget(&mut self, widget: ObjectId) -> bool {
        self.base.remove_widget(widget)
    }

    fn item_count(&self) -> usize {
        self.base.item_count()
    }

    fn item_at(&self, index: usize) -> Option<&LayoutItem> {
        self.base.item_at(index)
    }

    fn item_at_mut(&mut self, index: usize) -> Option<&mut LayoutItem> {
        self.base.item_at_mut(index)
    }

    fn clear(&mut self) {
        self.base.clear();
    }
    // ... continued below
}

Size Hints - Calculate based on the child:

impl Layout for CenteredLayout {
    // ... item management above

    fn size_hint<S: WidgetAccess>(&self, storage: &S) -> SizeHint {
        // Return cached hint if available
        if let Some(cached) = self.base.cached_size_hint() {
            return cached;
        }

        // Get child's size hint (if we have a child)
        let child_hint = if let Some(item) = self.base.item_at(0) {
            self.base.get_item_size_hint(storage, item)
        } else {
            SizeHint::default()
        };

        // Add margins
        let margins = self.base.content_margins();
        SizeHint {
            preferred: Size::new(
                child_hint.preferred.width + margins.horizontal(),
                child_hint.preferred.height + margins.vertical(),
            ),
            minimum: child_hint.minimum.map(|s| Size::new(
                s.width + margins.horizontal(),
                s.height + margins.vertical(),
            )),
            maximum: child_hint.maximum.map(|s| Size::new(
                s.width + margins.horizontal(),
                s.height + margins.vertical(),
            )),
        }
    }

    fn minimum_size<S: WidgetAccess>(&self, storage: &S) -> Size {
        self.size_hint(storage).effective_minimum()
    }

    fn size_policy(&self) -> SizePolicyPair {
        SizePolicyPair::default()
    }
    // ... continued below
}

Geometry - Delegate to LayoutBase:

impl Layout for CenteredLayout {
    // ... size hints above

    fn geometry(&self) -> Rect {
        self.base.geometry()
    }

    fn set_geometry(&mut self, rect: Rect) {
        self.base.set_geometry(rect);
    }

    fn content_margins(&self) -> ContentMargins {
        self.base.content_margins()
    }

    fn set_content_margins(&mut self, margins: ContentMargins) {
        self.base.set_content_margins(margins);
    }

    fn spacing(&self) -> f32 {
        self.base.spacing()
    }

    fn set_spacing(&mut self, spacing: f32) {
        self.base.set_spacing(spacing);
    }
    // ... continued below
}

Layout Calculation - The core algorithm:

impl Layout for CenteredLayout {
    // ... geometry above

    fn calculate<S: WidgetAccess>(&mut self, storage: &S, _available: Size) -> Size {
        let content_rect = self.base.content_rect();

        if let Some(item) = self.base.item_at(0) {
            // Get the child's preferred size
            let hint = self.base.get_item_size_hint(storage, item);

            // Constrain to available space
            let child_width = hint.preferred.width.min(content_rect.width());
            let child_height = hint.preferred.height.min(content_rect.height());

            // Calculate centered position
            let x = content_rect.origin.x + (content_rect.width() - child_width) / 2.0;
            let y = content_rect.origin.y + (content_rect.height() - child_height) / 2.0;

            // Store the calculated geometry
            self.base.set_item_geometry(0, Rect::new(x, y, child_width, child_height));
        }

        // Cache size hint for performance
        let hint = self.size_hint(storage);
        self.base.set_cached_size_hint(hint);
        self.base.mark_valid();

        self.base.geometry().size
    }

    fn apply<S: WidgetAccess>(&self, storage: &mut S) {
        // Apply calculated geometry to the widget
        if let Some(item) = self.base.item_at(0) {
            if let Some(geometry) = self.base.item_geometry(0) {
                LayoutBase::apply_item_geometry(storage, item, geometry);
            }
        }
    }
    // ... continued below
}

Invalidation and Ownership - Delegate to LayoutBase:

impl Layout for CenteredLayout {
    // ... calculate and apply above

    fn invalidate(&mut self) {
        self.base.invalidate();
    }

    fn needs_recalculation(&self) -> bool {
        self.base.needs_recalculation()
    }

    fn parent_widget(&self) -> Option<ObjectId> {
        self.base.parent_widget()
    }

    fn set_parent_widget(&mut self, parent: Option<ObjectId>) {
        self.base.set_parent_widget(parent);
    }
}

Using LayoutBase Helpers

LayoutBase provides several helper methods for implementing layouts:

use horizon_lattice::widget::layout::LayoutBase;

// In your calculate() implementation:
fn calculate<S: WidgetAccess>(&mut self, storage: &S, available: Size) -> Size {
    // Get the content area (geometry minus margins)
    let content = self.base.content_rect();

    // Iterate over items and check visibility
    for (i, item) in self.base.items().iter().enumerate() {
        // Skip hidden widgets
        if !self.base.is_item_visible(storage, item) {
            continue;
        }

        // Get size hint for this item
        let hint = self.base.get_item_size_hint(storage, item);

        // Get size policy for this item
        let policy = self.base.get_item_size_policy(storage, item);

        // Calculate position and store it
        let rect = Rect::new(/* your calculation */);
        self.base.set_item_geometry(i, rect);
    }

    // Count visible items (for spacing calculations)
    let visible_count = self.base.visible_item_count(storage);

    self.base.mark_valid();
    available
}

Space Distribution

For layouts that distribute space among multiple items, use LayoutBase::distribute_space:

use horizon_lattice::widget::geometry::{SizeHint, SizePolicy};

// Collect item information: (size_hint, policy, stretch_factor)
let items: Vec<(SizeHint, SizePolicy, u8)> = /* gather from items */;

// Calculate totals
let total_hint: f32 = items.iter().map(|(h, _, _)| h.preferred.width).sum();
let total_min: f32 = items.iter().map(|(h, _, _)| h.effective_minimum().width).sum();
let total_max: f32 = items.iter().map(|(h, _, _)| h.effective_maximum().width).sum();

// Distribute available space
let sizes = LayoutBase::distribute_space(
    &items,
    available_width,  // Total available space
    total_hint,       // Sum of preferred sizes
    total_min,        // Sum of minimum sizes
    total_max,        // Sum of maximum sizes
);

// sizes[i] is the width to assign to item i

RTL (Right-to-Left) Support

For horizontal layouts, support RTL text direction:

fn calculate<S: WidgetAccess>(&mut self, storage: &S, available: Size) -> Size {
    let content = self.base.content_rect();
    let mut x_pos: f32 = 0.0;

    for (i, item) in self.base.items().iter().enumerate() {
        let item_width = /* calculated width */;

        // Mirror x position for RTL layouts
        let x = self.base.mirror_x(x_pos, item_width, content.width());

        let rect = Rect::new(
            content.origin.x + x,
            content.origin.y,
            item_width,
            content.height(),
        );
        self.base.set_item_geometry(i, rect);

        x_pos += item_width + self.base.spacing();
    }

    self.base.mark_valid();
    available
}

Height-for-Width Layouts

Some layouts (like flow layouts) need to adjust height based on available width:

impl Layout for FlowingLayout {
    fn has_height_for_width(&self) -> bool {
        true
    }

    fn height_for_width<S: WidgetAccess>(&self, storage: &S, width: f32) -> Option<f32> {
        // Calculate how many rows needed at this width
        let mut current_x: f32 = 0.0;
        let mut current_row_height: f32 = 0.0;
        let mut total_height: f32 = 0.0;
        let content_width = width - self.base.content_margins().horizontal();

        for item in self.base.items() {
            let hint = self.base.get_item_size_hint(storage, item);
            let item_width = hint.preferred.width;
            let item_height = hint.preferred.height;

            if current_x + item_width > content_width && current_x > 0.0 {
                // Wrap to next row
                total_height += current_row_height + self.base.spacing();
                current_x = 0.0;
                current_row_height = 0.0;
            }

            current_row_height = current_row_height.max(item_height);
            current_x += item_width + self.base.spacing();
        }

        total_height += current_row_height;
        Some(total_height + self.base.content_margins().vertical())
    }
}

Complete Example: Diagonal Layout

Here’s a complete custom layout that arranges items diagonally:

use horizon_lattice::widget::layout::{Layout, LayoutBase, LayoutItem, ContentMargins};
use horizon_lattice::widget::geometry::{SizeHint, SizePolicyPair};
use horizon_lattice::widget::dispatcher::WidgetAccess;
use horizon_lattice_core::ObjectId;
use horizon_lattice_render::{Rect, Size};

/// Arranges items diagonally from top-left to bottom-right.
#[derive(Debug, Clone)]
pub struct DiagonalLayout {
    base: LayoutBase,
    /// Horizontal offset per item.
    x_offset: f32,
    /// Vertical offset per item.
    y_offset: f32,
}

impl DiagonalLayout {
    pub fn new(x_offset: f32, y_offset: f32) -> Self {
        Self {
            base: LayoutBase::new(),
            x_offset,
            y_offset,
        }
    }
}

impl Layout for DiagonalLayout {
    // Item management - delegate to base
    fn add_item(&mut self, item: LayoutItem) { self.base.add_item(item); }
    fn insert_item(&mut self, index: usize, item: LayoutItem) { self.base.insert_item(index, item); }
    fn remove_item(&mut self, index: usize) -> Option<LayoutItem> { self.base.remove_item(index) }
    fn remove_widget(&mut self, widget: ObjectId) -> bool { self.base.remove_widget(widget) }
    fn item_count(&self) -> usize { self.base.item_count() }
    fn item_at(&self, index: usize) -> Option<&LayoutItem> { self.base.item_at(index) }
    fn item_at_mut(&mut self, index: usize) -> Option<&mut LayoutItem> { self.base.item_at_mut(index) }
    fn clear(&mut self) { self.base.clear(); }

    fn size_hint<S: WidgetAccess>(&self, storage: &S) -> SizeHint {
        let margins = self.base.content_margins();
        let visible_count = self.base.visible_item_count(storage);
        let mut max_width: f32 = 0.0;
        let mut max_height: f32 = 0.0;

        for (i, item) in self.base.items().iter().enumerate() {
            if !self.base.is_item_visible(storage, item) { continue; }
            let hint = self.base.get_item_size_hint(storage, item);
            let x_end = (i as f32) * self.x_offset + hint.preferred.width;
            let y_end = (i as f32) * self.y_offset + hint.preferred.height;
            max_width = max_width.max(x_end);
            max_height = max_height.max(y_end);
        }

        SizeHint::new(Size::new(
            max_width + margins.horizontal(),
            max_height + margins.vertical(),
        ))
    }

    fn minimum_size<S: WidgetAccess>(&self, storage: &S) -> Size {
        self.size_hint(storage).effective_minimum()
    }

    fn size_policy(&self) -> SizePolicyPair { SizePolicyPair::default() }

    // Geometry - delegate to base
    fn geometry(&self) -> Rect { self.base.geometry() }
    fn set_geometry(&mut self, rect: Rect) { self.base.set_geometry(rect); }
    fn content_margins(&self) -> ContentMargins { self.base.content_margins() }
    fn set_content_margins(&mut self, margins: ContentMargins) { self.base.set_content_margins(margins); }
    fn spacing(&self) -> f32 { self.base.spacing() }
    fn set_spacing(&mut self, spacing: f32) { self.base.set_spacing(spacing); }

    fn calculate<S: WidgetAccess>(&mut self, storage: &S, available: Size) -> Size {
        let content = self.base.content_rect();
        let mut visible_index = 0;

        for (i, item) in self.base.items().iter().enumerate() {
            if !self.base.is_item_visible(storage, item) {
                self.base.set_item_geometry(i, Rect::ZERO);
                continue;
            }

            let hint = self.base.get_item_size_hint(storage, item);
            let x = content.origin.x + (visible_index as f32) * self.x_offset;
            let y = content.origin.y + (visible_index as f32) * self.y_offset;

            self.base.set_item_geometry(i, Rect::new(
                x,
                y,
                hint.preferred.width.min(content.width()),
                hint.preferred.height.min(content.height()),
            ));

            visible_index += 1;
        }

        self.base.mark_valid();
        available
    }

    fn apply<S: WidgetAccess>(&self, storage: &mut S) {
        for (i, item) in self.base.items().iter().enumerate() {
            if let Some(geometry) = self.base.item_geometry(i) {
                LayoutBase::apply_item_geometry(storage, item, geometry);
            }
        }
    }

    // Invalidation - delegate to base
    fn invalidate(&mut self) { self.base.invalidate(); }
    fn needs_recalculation(&self) -> bool { self.base.needs_recalculation() }
    fn parent_widget(&self) -> Option<ObjectId> { self.base.parent_widget() }
    fn set_parent_widget(&mut self, parent: Option<ObjectId>) { self.base.set_parent_widget(parent); }
}

Best Practices for Custom Layouts

  1. Always use LayoutBase - It handles caching, invalidation, and common operations
  2. Mark layout valid after calculation - Call self.base.mark_valid() at the end of calculate()
  3. Skip hidden items - Use is_item_visible() to skip hidden widgets
  4. Cache size hints - Use set_cached_size_hint() for performance
  5. Handle empty layouts - Return early if item_count() == 0
  6. Respect size policies - Use get_item_size_policy() to determine if items can grow/shrink
  7. Account for margins - Use content_rect() to get the area inside margins
  8. Test with RTL - If horizontal, test with RTL text direction

Default Layout Constants

The layout system provides sensible defaults:

use horizon_lattice::widget::layout::{DEFAULT_SPACING, DEFAULT_MARGINS};

// Default spacing between items
assert_eq!(DEFAULT_SPACING, 6.0);

// Default content margins
assert_eq!(DEFAULT_MARGINS.left, 9.0);
assert_eq!(DEFAULT_MARGINS.top, 9.0);
assert_eq!(DEFAULT_MARGINS.right, 9.0);
assert_eq!(DEFAULT_MARGINS.bottom, 9.0);

Best Practices

  1. Use appropriate layouts - VBox/HBox for linear arrangements, Grid for tables, Form for input forms
  2. Set size policies - Help layouts make better decisions about space distribution
  3. Use stretch factors - Control how extra space is distributed between widgets
  4. Nest layouts - Combine simple layouts for complex UIs rather than using one complex layout
  5. Set minimum sizes - Prevent layouts from shrinking widgets too small
  6. Use spacers - Add flexible space to push widgets apart or fill gaps

See the Layout Reference for all layout types.

Styling Guide

Horizon Lattice uses a CSS-like styling system for widget appearance.

Selectors

Type Selectors

Match widgets by type name:

use horizon_lattice_style::prelude::*;

// Simple type selector
let button = Selector::type_selector("Button");
assert_eq!(button.to_string(), "Button");

let label = Selector::type_selector("Label");
assert_eq!(label.to_string(), "Label");

// Universal selector (matches any widget)
let any = Selector::universal();
assert_eq!(any.to_string(), "*");

Class Selectors

Match widgets with a specific class:

use horizon_lattice_style::prelude::*;

// Class selector
let primary = Selector::class("primary");
assert_eq!(primary.to_string(), ".primary");

let danger = Selector::class("danger");
assert_eq!(danger.to_string(), ".danger");

// Combine type and class
let primary_button = Selector::type_selector("Button")
    .descendant(SelectorPart::class_only("primary"));
assert_eq!(primary_button.to_string(), "Button .primary");

ID Selectors

Match a specific widget by ID:

use horizon_lattice_style::prelude::*;

// ID selector
let submit = Selector::id("submit-button");
assert_eq!(submit.to_string(), "#submit-button");

let header = Selector::id("main-header");
assert_eq!(header.to_string(), "#main-header");

Pseudo-Classes

Match widget states:

use horizon_lattice_style::prelude::*;

// Create a selector with hover pseudo-class
let hover = Selector::type_selector("Button")
    .descendant(SelectorPart::new().with_pseudo(PseudoClass::Hover));
assert_eq!(hover.to_string(), "Button :hover");

// Button with pressed state
let pressed = SelectorPart::type_only("Button")
    .with_pseudo(PseudoClass::Pressed);
assert_eq!(pressed.to_string(), "Button:pressed");

// Available pseudo-classes
let _ = PseudoClass::Hover;     // Mouse over widget
let _ = PseudoClass::Pressed;   // Mouse button down
let _ = PseudoClass::Focused;   // Has keyboard focus
let _ = PseudoClass::Disabled;  // Widget is disabled
let _ = PseudoClass::Enabled;   // Widget is enabled
let _ = PseudoClass::Checked;   // For checkable widgets
let _ = PseudoClass::Unchecked; // For checkable widgets
let _ = PseudoClass::FirstChild;  // First among siblings
let _ = PseudoClass::LastChild;   // Last among siblings
let _ = PseudoClass::OnlyChild;   // Only child of parent
let _ = PseudoClass::Empty;       // Has no children

Combinators

Combine selectors for hierarchical matching:

use horizon_lattice_style::prelude::*;

// Descendant combinator (any depth)
let nested = Selector::type_selector("Container")
    .descendant(SelectorPart::type_only("Button"));
assert_eq!(nested.to_string(), "Container Button");

// Child combinator (direct child only)
let child = Selector::type_selector("Form")
    .child(SelectorPart::type_only("Label"));
assert_eq!(child.to_string(), "Form > Label");

// Multiple levels
let deep = Selector::type_selector("Window")
    .descendant(SelectorPart::type_only("Container"))
    .child(SelectorPart::class_only("button-row"))
    .descendant(SelectorPart::type_only("Button"));
assert_eq!(deep.to_string(), "Window Container > .button-row Button");

Specificity

CSS specificity determines which styles take precedence:

use horizon_lattice_style::prelude::*;

// Specificity is (IDs, Classes+PseudoClasses, Types)

// * -> (0,0,0)
let universal = Selector::universal();
assert_eq!(Specificity::of_selector(&universal), Specificity(0, 0, 0));

// Button -> (0,0,1)
let button = Selector::type_selector("Button");
assert_eq!(Specificity::of_selector(&button), Specificity(0, 0, 1));

// .primary -> (0,1,0)
let class = Selector::class("primary");
assert_eq!(Specificity::of_selector(&class), Specificity(0, 1, 0));

// #submit -> (1,0,0)
let id = Selector::id("submit");
assert_eq!(Specificity::of_selector(&id), Specificity(1, 0, 0));

// Button.primary:hover -> (0,2,1) = 1 type + 1 class + 1 pseudo-class
let complex = Selector {
    parts: vec![
        SelectorPart::type_only("Button")
            .with_class("primary")
            .with_pseudo(PseudoClass::Hover)
    ],
    combinators: vec![],
};
assert_eq!(Specificity::of_selector(&complex), Specificity(0, 2, 1));

// Higher specificity wins
assert!(Specificity(1, 0, 0) > Specificity(0, 99, 99)); // ID beats many classes
assert!(Specificity(0, 1, 0) > Specificity(0, 0, 99));  // Class beats many types

Building Selectors

Use the builder pattern for complex selectors:

use horizon_lattice_style::prelude::*;

// Build a selector programmatically
let selector = Selector::type_selector("Button")
    .child(SelectorPart::class_only("icon"))
    .descendant(SelectorPart::type_only("Image"));

assert_eq!(selector.to_string(), "Button > .icon Image");

// Get the subject (rightmost part)
let subject = selector.subject().unwrap();
assert!(matches!(subject.type_selector, Some(TypeSelector::Type(ref t)) if t == "Image"));

// Build a complex selector part
let part = SelectorPart::type_only("Button")
    .with_class("primary")
    .with_class("large")
    .with_pseudo(PseudoClass::Hover);
assert_eq!(part.to_string(), "Button.primary.large:hover");

Themes

Create and use themes for consistent styling:

use horizon_lattice_style::prelude::*;

// Use built-in themes
let light = Theme::light();
let dark = Theme::dark();
let high_contrast = Theme::high_contrast();

// Check theme mode
assert_eq!(light.mode, ThemeMode::Light);
assert_eq!(dark.mode, ThemeMode::Dark);
assert_eq!(high_contrast.mode, ThemeMode::HighContrast);

// Access theme colors
let primary_color = light.primary();
let background = light.background();
let text_color = light.text_color();

Nth-Child Expressions

Use nth-child for pattern-based selection:

use horizon_lattice_style::prelude::*;

// :nth-child(odd) matches 1st, 3rd, 5th... (2n+1)
let odd = NthExpr::odd();
assert!(odd.matches(0));   // 1st child (0-indexed)
assert!(!odd.matches(1));  // 2nd child
assert!(odd.matches(2));   // 3rd child

// :nth-child(even) matches 2nd, 4th, 6th... (2n)
let even = NthExpr::even();
assert!(!even.matches(0)); // 1st child
assert!(even.matches(1));  // 2nd child
assert!(!even.matches(2)); // 3rd child

// :nth-child(3) matches only the 3rd child
let third = NthExpr::new(0, 3);
assert!(!third.matches(0));
assert!(!third.matches(1));
assert!(third.matches(2));  // 3rd child (0-indexed = 2)

// Custom expression: every 3rd starting from 2nd (3n+2)
let custom = NthExpr::new(3, 2);
println!("Formula: {}", custom); // "3n+2"

CSS Pseudo-Class Parsing

Parse pseudo-classes from CSS strings:

use horizon_lattice_style::prelude::*;

// Parse standard pseudo-classes
assert_eq!(PseudoClass::from_css("hover"), Some(PseudoClass::Hover));
assert_eq!(PseudoClass::from_css("pressed"), Some(PseudoClass::Pressed));
assert_eq!(PseudoClass::from_css("active"), Some(PseudoClass::Pressed)); // CSS alias
assert_eq!(PseudoClass::from_css("focused"), Some(PseudoClass::Focused));
assert_eq!(PseudoClass::from_css("focus"), Some(PseudoClass::Focused)); // CSS alias
assert_eq!(PseudoClass::from_css("disabled"), Some(PseudoClass::Disabled));
assert_eq!(PseudoClass::from_css("first-child"), Some(PseudoClass::FirstChild));

// Unknown pseudo-class returns None
assert_eq!(PseudoClass::from_css("unknown"), None);

Specificity With Source Order

When specificity is equal, later rules win:

use horizon_lattice_style::prelude::*;

// Same specificity, different source order
let s1 = Specificity(0, 1, 0).with_order(1);
let s2 = Specificity(0, 1, 0).with_order(2);

// Higher order (later in stylesheet) wins
assert!(s2 > s1);

// But higher specificity always beats lower
let s3 = Specificity(0, 2, 0).with_order(0);
assert!(s3 > s1); // More specific, even though earlier
assert!(s3 > s2);

Theme Modes

Support different visual modes:

use horizon_lattice_style::prelude::*;

fn select_theme(user_preference: &str) -> Theme {
    match user_preference {
        "dark" => Theme::dark(),
        "high-contrast" => Theme::high_contrast(),
        _ => Theme::light(),
    }
}

// Check and respond to theme mode
let theme = Theme::dark();
match theme.mode {
    ThemeMode::Light => println!("Using light theme"),
    ThemeMode::Dark => println!("Using dark theme"),
    ThemeMode::HighContrast => println!("Using high contrast theme"),
}

Best Practices

  1. Use class selectors for reusable styles across widget types
  2. Use type selectors for widget-specific default styles
  3. Use ID selectors sparingly - they have high specificity and are harder to override
  4. Keep specificity low - makes styles easier to maintain and override
  5. Use combinators to scope styles without increasing specificity too much
  6. Leverage themes for consistent colors and spacing across your application
  7. Use pseudo-classes for interactive states instead of JavaScript-style state changes

Supported Properties

Box Model

  • margin, padding - Edge spacing
  • border-width, border-color, border-style - Borders
  • border-radius - Rounded corners

Colors

  • color - Text color
  • background-color - Background fill

Typography

  • font-size, font-weight, font-style
  • font-family - Font name or generic
  • text-align - left, center, right
  • line-height - Line spacing

Effects

  • opacity - 0.0 to 1.0

Cursor

  • cursor - pointer, text, etc.

See Style Properties Reference for the complete list.

Signals and Slots Guide

Signals are Horizon Lattice’s mechanism for event-driven programming. They provide type-safe, thread-safe communication between objects.

Basic Usage

Signals emit values that connected slots (callbacks) receive:

use horizon_lattice_core::Signal;

// Create a signal
let clicked = Signal::<()>::new();

// Connect a slot
let conn_id = clicked.connect(|_| {
    println!("Button clicked!");
});

// Emit the signal
clicked.emit(());

// Disconnect later if needed
clicked.disconnect(conn_id);

Signal Types

Parameterless Signals

For events that don’t carry data:

use horizon_lattice_core::Signal;

let clicked = Signal::<()>::new();
clicked.connect(|_| println!("Clicked!"));
clicked.emit(());

Signals with Parameters

For events that carry data:

use horizon_lattice_core::Signal;

// Single parameter
let text_changed = Signal::<String>::new();
text_changed.connect(|new_text| {
    println!("Text is: {}", new_text);
});
text_changed.emit("Hello".to_string());

// Primitive parameter (note the reference pattern)
let value_changed = Signal::<i32>::new();
value_changed.connect(|&value| {
    println!("Value: {}", value);
});
value_changed.emit(42);

Signals with Multiple Parameters

Use tuples for multiple values:

use horizon_lattice_core::Signal;

let position_changed = Signal::<(f32, f32)>::new();
position_changed.connect(|(x, y)| {
    println!("Position: ({}, {})", x, y);
});
position_changed.emit((100.0, 200.0));

Connection Types

Control how slots are invoked:

use horizon_lattice_core::{Signal, ConnectionType};

let signal = Signal::<i32>::new();

// Auto (default) - Direct if same thread, Queued if different
signal.connect(|&n| println!("Auto: {}", n));

// Direct - Called immediately, same thread
signal.connect_with_type(|&n| println!("Direct: {}", n), ConnectionType::Direct);

// Queued - Always posted to event loop (cross-thread safe)
signal.connect_with_type(|&n| println!("Queued: {}", n), ConnectionType::Queued);

signal.emit(42);

Connection Type Details

TypeBehaviorUse Case
AutoDirect if same thread, Queued otherwiseMost situations (default)
DirectImmediate, synchronous callSame-thread, performance critical
QueuedPosted to event loopCross-thread communication
BlockingQueuedQueued but blocks until completeSynchronization across threads

Creating Custom Signals

Embed signals in your types:

use horizon_lattice_core::{Signal, Property};

struct Counter {
    value: Property<i32>,
    value_changed: Signal<i32>,
}

impl Counter {
    pub fn new() -> Self {
        Self {
            value: Property::new(0),
            value_changed: Signal::new(),
        }
    }

    pub fn value_changed(&self) -> &Signal<i32> {
        &self.value_changed
    }

    pub fn value(&self) -> i32 {
        self.value.get()
    }

    pub fn set_value(&self, new_value: i32) {
        if self.value.set(new_value) {
            self.value_changed.emit(new_value);
        }
    }

    pub fn increment(&self) {
        self.set_value(self.value() + 1);
    }
}

// Usage
let counter = Counter::new();
counter.value_changed().connect(|&v| println!("Counter: {}", v));
counter.increment();  // Prints: Counter: 1
counter.increment();  // Prints: Counter: 2

Scoped Connections

Automatically disconnect when the guard is dropped (RAII pattern):

use horizon_lattice_core::Signal;
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;

let signal = Signal::<i32>::new();
let counter = Arc::new(AtomicI32::new(0));

{
    let counter_clone = counter.clone();
    let _guard = signal.connect_scoped(move |&n| {
        counter_clone.fetch_add(n, Ordering::SeqCst);
    });
    signal.emit(10);  // counter = 10
    // _guard is dropped here
}

signal.emit(20);  // Nothing happens, connection was dropped
assert_eq!(counter.load(Ordering::SeqCst), 10);

Blocking Signal Emission

Temporarily disable a signal:

use horizon_lattice_core::Signal;
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;

let signal = Signal::<i32>::new();
let counter = Arc::new(AtomicI32::new(0));

let counter_clone = counter.clone();
signal.connect(move |&n| {
    counter_clone.fetch_add(n, Ordering::SeqCst);
});

signal.emit(1);  // counter = 1
signal.set_blocked(true);
signal.emit(2);  // Blocked - nothing happens
signal.set_blocked(false);
signal.emit(3);  // counter = 4

assert_eq!(counter.load(Ordering::SeqCst), 4);

Thread Safety

Signals are thread-safe (Send + Sync). Cross-thread emissions are automatically handled:

use horizon_lattice_core::{Signal, ConnectionType};
use std::sync::Arc;
use std::sync::atomic::{AtomicI32, Ordering};

let signal = Arc::new(Signal::<i32>::new());
let counter = Arc::new(AtomicI32::new(0));

// Connect from main thread
let counter_clone = counter.clone();
signal.connect_with_type(move |&n| {
    counter_clone.fetch_add(n, Ordering::SeqCst);
}, ConnectionType::Direct);

// Emit from worker thread
let signal_clone = signal.clone();
let handle = std::thread::spawn(move || {
    signal_clone.emit(42);
});

handle.join().unwrap();
assert_eq!(counter.load(Ordering::SeqCst), 42);

Best Practices

  1. Keep slots short - Long operations should spawn background tasks
  2. Avoid blocking - Never block the main thread in a slot
  3. Use scoped connections - When the receiver has a shorter lifetime than the signal
  4. Don’t recurse - Emitting the same signal from its handler can cause infinite loops
  5. Use Direct for performance - When you know both sides are on the same thread
  6. Use Queued for safety - When crossing thread boundaries or uncertain

Common Patterns

One-shot Connection

Connect, emit once, then auto-disconnect:

use horizon_lattice_core::Signal;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

let signal = Signal::<()>::new();
let done = Arc::new(AtomicBool::new(false));

let done_clone = done.clone();
let id = signal.connect(move |_| {
    done_clone.store(true, Ordering::SeqCst);
});

signal.emit(());
signal.disconnect(id);  // Manually disconnect after first use

Forwarding Signals

Chain signals together:

use horizon_lattice_core::Signal;
use std::sync::Arc;

let source = Arc::new(Signal::<String>::new());
let destination = Arc::new(Signal::<String>::new());

// Forward from source to destination
let dest_clone = destination.clone();
source.connect(move |s| {
    dest_clone.emit(s.clone());
});

Threading Guide

Horizon Lattice follows a single-threaded UI model with support for background tasks.

Threading Model

  • Main thread: All UI operations must happen here
  • Worker threads: For CPU-intensive or blocking operations
  • Signal marshalling: Cross-thread signals are automatically queued

Main Thread Rule

UI widgets are not thread-safe. Always access them from the main thread:

use horizon_lattice::Application;

// BAD - Don't do this!
// std::thread::spawn(|| {
//     label.set_text("Updated");  // Undefined behavior!
// });

// GOOD - Post to main thread
fn update_label_safely(app: &Application) {
    app.post_task(|| {
        // UI operations are safe here - runs on main thread
        println!("This runs on the main thread!");
    });
}

Thread Pool

Use ThreadPool for CPU-intensive work:

use horizon_lattice_core::threadpool::{ThreadPool, ThreadPoolConfig};

// Create a custom thread pool
let pool = ThreadPool::new(ThreadPoolConfig::with_threads(4))
    .expect("Failed to create thread pool");

// Spawn a background task
let handle = pool.spawn(|| {
    // Heavy computation here
    let mut sum = 0u64;
    for i in 0..1_000_000 {
        sum += i;
    }
    sum
});

// Wait for the result
let result = handle.wait();
assert_eq!(result, Some(499999500000));

Thread Pool with UI Callbacks

Spawn tasks that deliver results to the main thread:

use horizon_lattice_core::threadpool::ThreadPool;

let pool = ThreadPool::global();

// Spawn a task that delivers its result to the UI thread
pool.spawn_with_callback(
    || {
        // Background work - runs on worker thread
        std::thread::sleep(std::time::Duration::from_millis(100));
        "computed result".to_string()
    },
    |result| {
        // This callback runs on the UI thread
        println!("Got result: {}", result);
    },
);

Cancellable Tasks

Use CancellationToken for cooperative task cancellation:

use horizon_lattice_core::threadpool::{ThreadPool, ThreadPoolConfig, CancellationToken};
use std::time::Duration;

let pool = ThreadPool::new(ThreadPoolConfig::with_threads(2)).unwrap();

let (handle, token) = pool.spawn_cancellable(|cancel_token| {
    for i in 0..100 {
        if cancel_token.is_cancelled() {
            return format!("Cancelled at step {}", i);
        }
        std::thread::sleep(Duration::from_millis(10));
    }
    "Completed".to_string()
});

// Cancel after a short delay
std::thread::sleep(Duration::from_millis(50));
token.cancel();

// The task will return early due to cancellation
let result = handle.wait();
assert!(result.is_some());
println!("Task result: {:?}", result);

Worker Objects

For persistent background workers that process tasks sequentially:

use horizon_lattice_core::worker::Worker;
use std::sync::{Arc, atomic::{AtomicI32, Ordering}};

// Create a worker that produces String results
let worker = Worker::<String>::new();
let counter = Arc::new(AtomicI32::new(0));

// Connect to the result signal
let counter_clone = counter.clone();
worker.on_result().connect(move |result| {
    println!("Worker produced: {}", result);
    counter_clone.fetch_add(1, Ordering::SeqCst);
});

// Send tasks to the worker (processed sequentially)
worker.send(|| "Task 1 complete".to_string());
worker.send(|| "Task 2 complete".to_string());

// Wait for processing
std::thread::sleep(std::time::Duration::from_millis(100));

// Graceful shutdown
worker.stop();
worker.join();

assert!(counter.load(Ordering::SeqCst) >= 1);

Worker with Callbacks

Send tasks with direct callbacks that bypass the signal:

use horizon_lattice_core::worker::Worker;
use std::sync::{Arc, Mutex};

let worker = Worker::<i32>::new();
let result_holder = Arc::new(Mutex::new(None));

let result_clone = result_holder.clone();
worker.send_with_callback(
    || {
        // Compute something
        42 * 2
    },
    move |result| {
        // Callback receives the result
        *result_clone.lock().unwrap() = Some(result);
    },
);

// Wait for processing
std::thread::sleep(std::time::Duration::from_millis(100));

assert_eq!(*result_holder.lock().unwrap(), Some(84));

worker.stop_and_join();

Progress Reporting

Report progress from background tasks:

use horizon_lattice_core::progress::ProgressReporter;
use std::sync::{Arc, Mutex};

let reporter = ProgressReporter::new();
let progress_values = Arc::new(Mutex::new(Vec::new()));

// Connect to progress updates
let values_clone = progress_values.clone();
reporter.on_progress_changed().connect(move |&progress| {
    values_clone.lock().unwrap().push(progress);
});

// Simulate progress updates
reporter.set_progress(0.25);
reporter.set_progress(0.50);
reporter.set_progress(0.75);
reporter.set_progress(1.0);

// Verify progress was tracked
let values = progress_values.lock().unwrap();
assert!(values.len() >= 4);
assert!((reporter.progress() - 1.0).abs() < f32::EPSILON);

Progress with Status Messages

Combine progress values with status messages:

use horizon_lattice_core::progress::ProgressReporter;

let reporter = ProgressReporter::new();

// Connect to combined updates
reporter.on_updated().connect(|update| {
    if let Some(ref msg) = update.message {
        println!("Progress: {:.0}% - {}", update.progress * 100.0, msg);
    }
});

// Update both progress and message atomically
reporter.update(0.25, "Loading resources...");
reporter.update(0.50, "Processing data...");
reporter.update(0.75, "Generating output...");
reporter.update(1.0, "Complete!");

assert_eq!(reporter.message(), Some("Complete!".to_string()));

Aggregate Progress

For multi-step operations, combine weighted sub-tasks:

use horizon_lattice_core::progress::AggregateProgress;

let mut aggregate = AggregateProgress::new();

// Add weighted sub-tasks (weight determines contribution to total)
let download = aggregate.add_task("download", 3.0);  // 75% of total weight
let process = aggregate.add_task("process", 1.0);    // 25% of total weight

// Initial state
assert_eq!(aggregate.progress(), 0.0);

// Complete download only (75% of total due to weight)
download.set_progress(1.0);
assert!((aggregate.progress() - 0.75).abs() < 0.01);

// Complete processing (now at 100%)
process.set_progress(1.0);
assert!((aggregate.progress() - 1.0).abs() < 0.01);

Tasks with Progress Reporting

Combine thread pool tasks with progress reporting:

use horizon_lattice_core::threadpool::{ThreadPool, ThreadPoolConfig};
use std::time::Duration;

let pool = ThreadPool::new(ThreadPoolConfig::with_threads(2)).unwrap();

let (handle, token, reporter) = pool.spawn_with_progress(|cancel, progress| {
    for i in 0..=10 {
        if cancel.is_cancelled() {
            return "Cancelled".to_string();
        }
        progress.update(i as f32 / 10.0, format!("Step {} of 10", i));
        std::thread::sleep(Duration::from_millis(5));
    }
    "Complete".to_string()
});

// Connect to progress updates
reporter.on_progress_changed().connect(|&p| {
    println!("Progress: {:.0}%", p * 100.0);
});

// Wait for completion
let result = handle.wait();
assert_eq!(result, Some("Complete".to_string()));
assert!((reporter.progress() - 1.0).abs() < f32::EPSILON);

Thread Safety Checks

The framework includes thread affinity checking:

use horizon_lattice_core::thread_check::{is_main_thread, main_thread_id};

// Check if we're on the main thread
if is_main_thread() {
    println!("Running on main thread");
} else {
    println!("Running on a background thread");
}

// Get the main thread ID (set when Application is created)
if let Some(id) = main_thread_id() {
    println!("Main thread ID: {:?}", id);
}

Best Practices

  1. Never block the main thread - Keep UI responsive
  2. Minimize cross-thread communication - Batch updates when possible
  3. Use signals for thread communication - They handle marshalling automatically
  4. Prefer async for I/O - Don’t waste threads waiting on network/disk
  5. Check cancellation tokens - Enable graceful shutdown of long-running tasks
  6. Use progress reporters - Keep users informed about long operations

Tutorial: Hello World

Build your first Horizon Lattice application.

What You’ll Learn

  • Creating an Application instance
  • Showing a Window
  • Adding a Label widget
  • Understanding the basic structure

Prerequisites

  • Rust installed (1.89+)
  • A new Cargo project

Project Setup

Create a new Rust project:

cargo new hello-lattice
cd hello-lattice

Add Horizon Lattice to Cargo.toml:

[dependencies]
horizon-lattice = "1.0"

Step 1: The Minimal Application

Every Horizon Lattice application starts with creating an Application. This initializes the event loop, graphics context, and platform integration.

Replace src/main.rs with:

use horizon_lattice::Application;

fn main() -> Result<(), horizon_lattice::LatticeError> {
    // Initialize the application (must be first)
    let app = Application::new()?;

    // Run the event loop (blocks until quit)
    app.run()
}

This compiles and runs, but does nothing visible because there’s no window.

Step 2: Create a Window

Windows are top-level containers for your UI. Import the Window widget and create one:

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::Window;

fn main() -> Result<(), horizon_lattice::LatticeError> {
    let app = Application::new()?;

    // Create a window with title and size
    let mut window = Window::new("Hello, World!")
        .with_size(400.0, 300.0);

    // Show the window
    window.show();

    app.run()
}

Now when you run the application, you’ll see an empty window titled “Hello, World!” that’s 400x300 pixels.

Window Properties

Windows support many properties via the builder pattern:

use horizon_lattice::widget::widgets::WindowFlags;

let mut window = Window::new("My App")
    .with_size(800.0, 600.0)           // Width x Height
    .with_position(100.0, 100.0)       // X, Y position
    .with_minimum_size(320.0, 240.0)   // Minimum allowed size
    .with_flags(WindowFlags::DEFAULT);

Step 3: Add a Label

Labels display text. Let’s add one to our window:

use horizon_lattice::prelude::*;

fn main() -> Result<(), horizon_lattice::LatticeError> {
    let app = Application::new()?;

    // Create a window
    let mut window = Window::new("Hello, World!")
        .with_size(400.0, 300.0);

    // Create a label
    let label = Label::new("Hello, World!");

    // Set the label as the window's content widget
    window.set_content_widget(label.object_id());
    window.show();

    app.run()
}

Run this and you’ll see “Hello, World!” displayed in the window.

Step 4: Style the Label

Labels support various styling options:

use horizon_lattice::prelude::*;
use horizon_lattice::render::HorizontalAlign;

fn main() -> Result<(), horizon_lattice::LatticeError> {
    let app = Application::new()?;

    let mut window = Window::new("Hello, World!")
        .with_size(400.0, 300.0);

    // Create a styled label
    let label = Label::new("Hello, World!")
        .with_horizontal_align(HorizontalAlign::Center)
        .with_text_color(Color::from_rgb8(50, 100, 200));

    window.set_content_widget(label.object_id());
    window.show();

    app.run()
}

Now the text is centered and colored blue.

Label Options

Labels support many display options:

use horizon_lattice::widget::widgets::{Label, ElideMode};

// Word wrapping for long text
let wrapped = Label::new("This is a very long text that will wrap to multiple lines")
    .with_word_wrap(true);

// Text elision (truncation with "...")
let elided = Label::new("very_long_filename_that_doesnt_fit.txt")
    .with_elide_mode(ElideMode::Right);  // Shows "very_long_filen..."

// Rich text with HTML
let rich = Label::from_html("Hello <b>bold</b> and <i>italic</i>!");

Complete Example

Here’s the complete Hello World application:

use horizon_lattice::prelude::*;
use horizon_lattice::render::HorizontalAlign;

fn main() -> Result<(), horizon_lattice::LatticeError> {
    // Initialize the application
    let app = Application::new()?;

    // Create the main window
    let mut window = Window::new("Hello, World!")
        .with_size(400.0, 300.0);

    // Create a centered, styled label
    let label = Label::new("Hello, Horizon Lattice!")
        .with_horizontal_align(HorizontalAlign::Center)
        .with_text_color(Color::from_rgb8(50, 100, 200));

    // Set up the window
    window.set_content_widget(label.object_id());
    window.show();

    // Run until window is closed
    app.run()
}

Understanding the Code

Application Singleton

let app = Application::new()?;

The Application is a singleton - only one can exist per process. It:

  • Initializes the graphics system (wgpu)
  • Sets up the event loop (winit)
  • Registers the main thread for thread-safety checks
  • Creates the global object registry

Window Lifecycle

let mut window = Window::new("Title")
    .with_size(400.0, 300.0);
window.show();

Windows are created hidden by default. Call show() to make them visible. The builder pattern (with_* methods) allows fluent configuration.

Content Widget

window.set_content_widget(label.object_id());

Each window has a content widget that fills its content area. You pass the widget’s ObjectId (obtained via object_id()). For more complex UIs, you’ll set a ContainerWidget with a layout as the content widget.

Event Loop

app.run()

This starts the event loop, which:

  • Processes user input (mouse, keyboard)
  • Dispatches signals
  • Redraws widgets as needed
  • Handles window management

The function blocks until all windows are closed (or Application::quit() is called).

Run It

cargo run

You should see a window with centered blue text saying “Hello, Horizon Lattice!”.

Next Steps

Tutorial: Button Clicks

Learn how to add interactivity with buttons and the signal/slot pattern.

What You’ll Learn

  • Creating buttons with PushButton
  • Connecting to the clicked signal
  • Handling events with closures
  • Toggle buttons and state management
  • Updating UI in response to clicks

Prerequisites

  • Completed the Hello World tutorial
  • Understanding of Rust closures

Step 1: A Simple Clickable Button

Let’s start with a button that prints a message when clicked:

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{PushButton, Window};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("Button Click")
        .with_size(400.0, 300.0);

    // Create a button
    let button = PushButton::new("Click me!");

    // Connect to the clicked signal
    button.clicked().connect(|&checked| {
        println!("Button clicked! Checked: {}", checked);
    });

    window.set_content_widget(button.object_id());
    window.show();

    app.run()
}

Run this and click the button - you’ll see “Button clicked! Checked: false” printed to the console.

Understanding Signals

Signals are Horizon Lattice’s way of communicating events. They’re inspired by Qt’s signal/slot mechanism but are fully type-safe at compile time.

The clicked Signal

// The clicked signal carries a bool indicating checked state
button.clicked().connect(|&checked: &bool| {
    // `checked` is false for normal buttons
    // `checked` is true/false for toggle buttons
});

Available Button Signals

PushButton provides four signals:

// Emitted when button is clicked (completed press + release)
button.clicked().connect(|&checked| { /* ... */ });

// Emitted when mouse button is pressed down
button.pressed().connect(|&()| { /* ... */ });

// Emitted when mouse button is released
button.released().connect(|&()| { /* ... */ });

// Emitted when checked state changes (toggle buttons only)
button.toggled().connect(|&checked| { /* ... */ });

Step 2: Toggle Buttons

Toggle buttons maintain a checked/unchecked state:

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{PushButton, Window};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("Toggle Button")
        .with_size(400.0, 300.0);

    // Create a toggle button
    let toggle = PushButton::new("Toggle me")
        .with_checkable(true);

    // React to toggle state changes
    toggle.toggled().connect(|&checked| {
        if checked {
            println!("Toggle is ON");
        } else {
            println!("Toggle is OFF");
        }
    });

    window.set_content_widget(toggle.object_id());
    window.show();

    app.run()
}

The button visually changes when toggled, and you can query its state with is_checked().

Step 3: Updating a Label from a Button

To update UI elements from a signal handler, you need to share state. Use Arc for thread-safe sharing:

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{Label, PushButton, Container, Window};
use horizon_lattice::widget::layout::{VBoxLayout, LayoutKind};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("Counter")
        .with_size(300.0, 200.0);

    // Shared counter state
    let count = Arc::new(AtomicU32::new(0));

    // Create widgets
    let label = Label::new("Count: 0");
    let button = PushButton::new("Increment");

    // Connect button to update label
    let label_clone = label.clone();
    let count_clone = count.clone();
    button.clicked().connect(move |_| {
        let new_count = count_clone.fetch_add(1, Ordering::SeqCst) + 1;
        label_clone.set_text(&format!("Count: {}", new_count));
    });

    // Layout the widgets vertically
    let mut layout = VBoxLayout::new();
    layout.add_widget(label.object_id());
    layout.add_widget(button.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(layout));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

Key Concepts

  1. Clone before move: Clone label and count before using in the closure
  2. move closure: Takes ownership of cloned values
  3. Thread-safe state: Use AtomicU32 (or Mutex for complex state)

Step 4: Multiple Buttons

Handle multiple buttons with different actions:

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{Label, PushButton, Container, Window};
use horizon_lattice::widget::layout::{HBoxLayout, LayoutKind};
use std::sync::Arc;
use std::sync::atomic::{AtomicI32, Ordering};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("Counter")
        .with_size(300.0, 150.0);

    let count = Arc::new(AtomicI32::new(0));

    let label = Label::new("0");
    let decrement = PushButton::new("-");
    let increment = PushButton::new("+");

    // Decrement button
    let label_clone = label.clone();
    let count_clone = count.clone();
    decrement.clicked().connect(move |_| {
        let new_value = count_clone.fetch_sub(1, Ordering::SeqCst) - 1;
        label_clone.set_text(&new_value.to_string());
    });

    // Increment button
    let label_clone = label.clone();
    let count_clone = count.clone();
    increment.clicked().connect(move |_| {
        let new_value = count_clone.fetch_add(1, Ordering::SeqCst) + 1;
        label_clone.set_text(&new_value.to_string());
    });

    // Horizontal layout: [-] [0] [+]
    let mut layout = HBoxLayout::new();
    layout.add_widget(decrement.object_id());
    layout.add_widget(label.object_id());
    layout.add_widget(increment.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(layout));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

Step 5: Button Variants

PushButton supports different visual styles:

use horizon_lattice::widget::widgets::{PushButton, ButtonVariant};

// Primary (default) - filled with primary color
let primary = PushButton::new("Primary");

// Secondary - outlined with primary color
let secondary = PushButton::new("Secondary")
    .with_variant(ButtonVariant::Secondary);

// Danger - filled with error/red color
let danger = PushButton::new("Delete")
    .with_variant(ButtonVariant::Danger);

// Flat - text only, no background
let flat = PushButton::new("Cancel")
    .with_variant(ButtonVariant::Flat);

// Outlined - outlined with neutral border
let outlined = PushButton::new("Options")
    .with_variant(ButtonVariant::Outlined);

Step 6: Default Button

Mark a button as the “default” to activate it with Enter key:

use horizon_lattice::widget::widgets::PushButton;

let ok_button = PushButton::new("OK")
    .with_default(true);

let cancel_button = PushButton::new("Cancel");

The default button:

  • Has enhanced visual styling (prominent border)
  • Activates when Enter is pressed anywhere in the window

Complete Example: Interactive Counter

Here’s a polished counter application:

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{
    Label, PushButton, Container, Window, ButtonVariant
};
use horizon_lattice::widget::layout::{HBoxLayout, VBoxLayout, ContentMargins, LayoutKind};
use horizon_lattice::render::{HorizontalAlign, VerticalAlign};
use std::sync::Arc;
use std::sync::atomic::{AtomicI32, Ordering};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("Counter App")
        .with_size(300.0, 200.0);

    // Shared state
    let count = Arc::new(AtomicI32::new(0));

    // Title label
    let title = Label::new("Interactive Counter")
        .with_horizontal_align(HorizontalAlign::Center);

    // Count display
    let display = Label::new("0")
        .with_horizontal_align(HorizontalAlign::Center)
        .with_vertical_align(VerticalAlign::Center);

    // Buttons
    let decrement = PushButton::new("-5")
        .with_variant(ButtonVariant::Secondary);
    let increment = PushButton::new("+5");
    let reset = PushButton::new("Reset")
        .with_variant(ButtonVariant::Danger);

    // Connect decrement
    let display_clone = display.clone();
    let count_clone = count.clone();
    decrement.clicked().connect(move |_| {
        let new_value = count_clone.fetch_sub(5, Ordering::SeqCst) - 5;
        display_clone.set_text(&new_value.to_string());
    });

    // Connect increment
    let display_clone = display.clone();
    let count_clone = count.clone();
    increment.clicked().connect(move |_| {
        let new_value = count_clone.fetch_add(5, Ordering::SeqCst) + 5;
        display_clone.set_text(&new_value.to_string());
    });

    // Connect reset
    let display_clone = display.clone();
    let count_clone = count.clone();
    reset.clicked().connect(move |_| {
        count_clone.store(0, Ordering::SeqCst);
        display_clone.set_text("0");
    });

    // Button row layout
    let mut button_row = HBoxLayout::new();
    button_row.set_spacing(10.0);
    button_row.add_widget(decrement.object_id());
    button_row.add_widget(increment.object_id());

    let mut button_container = Container::new();
    button_container.set_layout(LayoutKind::from(button_row));

    // Main vertical layout
    let mut main_layout = VBoxLayout::new();
    main_layout.set_spacing(15.0);
    main_layout.set_content_margins(ContentMargins::uniform(20.0));
    main_layout.add_widget(title.object_id());
    main_layout.add_widget(display.object_id());
    main_layout.add_widget(button_container.object_id());
    main_layout.add_widget(reset.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(main_layout));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

Signal Connection Types

For advanced use cases, you can specify connection types:

use horizon_lattice::ConnectionType;

// Direct: immediate execution (default, same thread only)
button.clicked().connect_with_type(ConnectionType::Direct, |_| {
    // Runs immediately when signal emits
});

// Queued: deferred to event loop (thread-safe)
button.clicked().connect_with_type(ConnectionType::Queued, |_| {
    // Runs on the main thread via event loop
});

// Auto: automatically chooses based on context (recommended)
button.clicked().connect_with_type(ConnectionType::Auto, |_| {
    // Direct if same thread, Queued if cross-thread
});

Disconnecting Signals

Save the connection ID to disconnect later:

// Connect and save the ID
let connection_id = button.clicked().connect(|_| {
    println!("Connected!");
});

// Later, disconnect
button.clicked().disconnect(connection_id);

Next Steps

Tutorial: Forms and Validation

Learn to build input forms with validation and proper layout.

What You’ll Learn

  • Using input widgets (LineEdit, CheckBox, ComboBox, SpinBox)
  • Organizing forms with FormLayout
  • Input validation patterns
  • Collecting and processing form data

Prerequisites

Step 1: Text Input with LineEdit

LineEdit is for single-line text input:

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{LineEdit, Label, Container, Window};
use horizon_lattice::widget::layout::{VBoxLayout, LayoutKind};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("Text Input")
        .with_size(400.0, 200.0);

    // Create a text input
    let mut name_input = LineEdit::new();
    name_input.set_placeholder("Enter your name...");

    // React to text changes
    name_input.text_changed.connect(|text| {
        println!("Text changed: {}", text);
    });

    // React to Enter key
    name_input.return_pressed.connect(|| {
        println!("Enter pressed!");
    });

    let label = Label::new("Name:");

    let mut layout = VBoxLayout::new();
    layout.add_widget(label.object_id());
    layout.add_widget(name_input.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(layout));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

LineEdit Features

use horizon_lattice::widget::widgets::{LineEdit, EchoMode};

// Password field
let mut password = LineEdit::new()
    .with_echo_mode(EchoMode::Password);

// With initial text
let mut edit = LineEdit::with_text("Initial value");

// Read-only field
let mut display = LineEdit::new();
display.set_read_only(true);
display.set_text("Cannot edit this");

// With maximum length
let mut short = LineEdit::new();
short.set_max_length(Some(10));

// With clear button
let mut searchbox = LineEdit::new();
searchbox.set_clear_button(true);
searchbox.set_placeholder("Search...");

LineEdit Signals

// Text changed (after validation passes)
edit.text_changed.connect(|text| { /* ... */ });

// Text edited (before validation, raw input)
edit.text_edited.connect(|text| { /* ... */ });

// Enter/Return key pressed
edit.return_pressed.connect(|| { /* ... */ });

// Focus lost or Enter pressed
edit.editing_finished.connect(|| { /* ... */ });

// Clear button clicked
edit.cleared.connect(|| { /* ... */ });

// Input rejected by validator
edit.input_rejected.connect(|| { /* ... */ });

Step 2: Checkboxes

CheckBox provides binary or tri-state selection:

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{CheckBox, CheckState, Label, Container, Window};
use horizon_lattice::widget::layout::{VBoxLayout, LayoutKind};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("Checkboxes")
        .with_size(300.0, 200.0);

    // Simple checkbox
    let terms = CheckBox::new("I accept the terms and conditions");

    // Pre-checked checkbox
    let newsletter = CheckBox::new("Subscribe to newsletter")
        .with_checked(true);

    // React to state changes
    terms.state_changed().connect(|&state| {
        match state {
            CheckState::Checked => println!("Terms accepted"),
            CheckState::Unchecked => println!("Terms declined"),
            CheckState::PartiallyChecked => println!("Partial"),
        }
    });

    // Boolean signal (simpler)
    newsletter.toggled().connect(|&checked| {
        println!("Newsletter: {}", if checked { "yes" } else { "no" });
    });

    let mut layout = VBoxLayout::new();
    layout.add_widget(terms.object_id());
    layout.add_widget(newsletter.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(layout));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

Tri-State Checkboxes

For “select all” patterns:

use horizon_lattice::widget::widgets::{CheckBox, CheckState};

// Enable tri-state mode
let mut select_all = CheckBox::new("Select all")
    .with_tri_state(true);

// Set partial state (e.g., when some children are checked)
select_all.set_check_state(CheckState::PartiallyChecked);

// State cycles: Unchecked -> Checked -> PartiallyChecked -> Unchecked
select_all.toggle();

Step 3: Dropdown Selection with ComboBox

ComboBox provides dropdown selection:

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{ComboBox, Label, Container, Window};
use horizon_lattice::widget::layout::{VBoxLayout, LayoutKind};
use horizon_lattice::model::StringListComboModel;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("Dropdown")
        .with_size(300.0, 200.0);

    // Create model with items
    let countries = vec!["United States", "Canada", "Mexico", "United Kingdom"];
    let model = StringListComboModel::from(countries);

    // Create combo box
    let mut combo = ComboBox::new()
        .with_model(Box::new(model));

    // Set default selection
    combo.set_current_index(0);

    // React to selection changes
    combo.current_index_changed.connect(|&index| {
        println!("Selected index: {}", index);
    });

    combo.current_text_changed.connect(|text| {
        println!("Selected: {}", text);
    });

    let label = Label::new("Country:");

    let mut layout = VBoxLayout::new();
    layout.add_widget(label.object_id());
    layout.add_widget(combo.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(layout));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

Editable ComboBox

Allow typing to filter or enter custom values:

use horizon_lattice::widget::widgets::ComboBox;
use horizon_lattice::model::StringListComboModel;

let fruits = vec!["Apple", "Apricot", "Avocado", "Banana", "Blueberry"];
let model = StringListComboModel::from(fruits);

let mut combo = ComboBox::new()
    .with_model(Box::new(model))
    .with_editable(true)
    .with_placeholder("Type to filter...");

// Typing "Ap" filters to: Apple, Apricot
// User can also enter a custom value not in the list

ComboBox Methods

// Get current selection
let index = combo.current_index();  // -1 if nothing selected
let text = combo.current_text();

// Set selection
combo.set_current_index(2);
combo.set_current_text("Canada");

// Find item
if let Some(idx) = combo.find_text("Mexico") {
    combo.set_current_index(idx as i32);
}

// Item count
let count = combo.count();

// Popup control
combo.show_popup();
combo.hide_popup();

Step 4: Numeric Input with SpinBox

SpinBox is for integer input with increment/decrement:

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{SpinBox, Label, Container, Window};
use horizon_lattice::widget::layout::{VBoxLayout, LayoutKind};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("Numeric Input")
        .with_size(300.0, 200.0);

    // Create a spinbox with range
    let mut age = SpinBox::new()
        .with_range(0, 120)
        .with_value(25)
        .with_single_step(1);

    // React to value changes
    age.value_changed.connect(|&value| {
        println!("Age: {}", value);
    });

    let label = Label::new("Age:");

    let mut layout = VBoxLayout::new();
    layout.add_widget(label.object_id());
    layout.add_widget(age.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(layout));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

SpinBox Features

use horizon_lattice::widget::widgets::SpinBox;

// With prefix and suffix
let mut price = SpinBox::new()
    .with_range(0, 9999)
    .with_prefix("$")
    .with_suffix(".00");

// With special value text (shown at minimum)
let mut quantity = SpinBox::new()
    .with_range(0, 100)
    .with_special_value_text("Auto");  // Shows "Auto" when value is 0

// Wrapping (loops from max to min)
let mut hour = SpinBox::new()
    .with_range(0, 23)
    .with_wrapping(true);  // 23 + 1 = 0

// Larger step size
let mut percent = SpinBox::new()
    .with_range(0, 100)
    .with_single_step(5)
    .with_suffix("%");

// With acceleration on hold
let mut fast = SpinBox::new()
    .with_range(0, 1000)
    .with_acceleration(true);

Step 5: FormLayout

FormLayout automatically aligns label-field pairs:

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{
    Label, LineEdit, SpinBox, CheckBox, ComboBox, PushButton, Container, Window
};
use horizon_lattice::widget::layout::{FormLayout, FieldGrowthPolicy, Alignment, LayoutKind};
use horizon_lattice::model::StringListComboModel;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("Registration Form")
        .with_size(400.0, 350.0);

    // Create labels
    let name_label = Label::new("Full Name:");
    let email_label = Label::new("Email:");
    let age_label = Label::new("Age:");
    let country_label = Label::new("Country:");
    let subscribe_label = Label::new("Newsletter:");

    // Create fields
    let mut name_field = LineEdit::new();
    name_field.set_placeholder("Enter your name");

    let mut email_field = LineEdit::new();
    email_field.set_placeholder("user@example.com");

    let age_field = SpinBox::new()
        .with_range(13, 120)
        .with_value(18);

    let countries = vec!["United States", "Canada", "Mexico", "Other"];
    let country_model = StringListComboModel::from(countries);
    let mut country_field = ComboBox::new()
        .with_model(Box::new(country_model));
    country_field.set_current_index(0);

    let subscribe_field = CheckBox::new("Yes, send me updates");

    // Create form layout
    let mut form = FormLayout::new();

    // Add label-field pairs
    form.add_row(name_label.object_id(), name_field.object_id());
    form.add_row(email_label.object_id(), email_field.object_id());
    form.add_row(age_label.object_id(), age_field.object_id());
    form.add_row(country_label.object_id(), country_field.object_id());
    form.add_row(subscribe_label.object_id(), subscribe_field.object_id());

    // Configure layout
    form.set_label_alignment(Alignment::End);  // Right-align labels
    form.set_field_growth_policy(FieldGrowthPolicy::AllNonFixedFieldsGrow);
    form.set_horizontal_spacing(12.0);
    form.set_vertical_spacing(10.0);

    // Add submit button spanning full width
    let submit = PushButton::new("Register");
    form.add_spanning_widget(submit.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(form));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

FormLayout Configuration

use horizon_lattice::widget::layout::{FormLayout, FieldGrowthPolicy, RowWrapPolicy, Alignment};

let mut form = FormLayout::new();

// Label alignment
form.set_label_alignment(Alignment::Start);  // Left-align (Windows style)
form.set_label_alignment(Alignment::End);    // Right-align (macOS style)

// Field growth policy
form.set_field_growth_policy(FieldGrowthPolicy::FieldsStayAtSizeHint);  // Fixed width
form.set_field_growth_policy(FieldGrowthPolicy::ExpandingFieldsGrow);   // Only Expanding fields grow
form.set_field_growth_policy(FieldGrowthPolicy::AllNonFixedFieldsGrow); // All non-Fixed grow

// Row wrapping (for narrow windows)
form.set_row_wrap_policy(RowWrapPolicy::DontWrapRows);  // Label beside field
form.set_row_wrap_policy(RowWrapPolicy::WrapAllRows);   // Label above field

// Spacing
form.set_horizontal_spacing(12.0);  // Between label and field
form.set_vertical_spacing(8.0);     // Between rows

Step 6: Input Validation

Use validators to constrain input:

use horizon_lattice::widget::widgets::LineEdit;
use horizon_lattice::widget::validator::{IntValidator, DoubleValidator, RegexValidator};
use std::sync::Arc;

// Integer validator (e.g., for age 0-150)
let mut age_input = LineEdit::new();
age_input.set_validator(Arc::new(IntValidator::new(0, 150)));

// Double validator (e.g., for price with 2 decimals)
let mut price_input = LineEdit::new();
price_input.set_validator(Arc::new(DoubleValidator::new(0.0, 9999.99, 2)));

// Regex validator (e.g., for email pattern)
let mut email_input = LineEdit::new();
email_input.set_validator(Arc::new(RegexValidator::new(
    r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
)));

// Handle rejected input
age_input.input_rejected.connect(|| {
    println!("Invalid age entered!");
});

Validation States

use horizon_lattice::widget::validator::ValidationState;

// ValidationState::Invalid      - Clearly wrong (e.g., "abc" for number)
// ValidationState::Intermediate - Could become valid (e.g., "" or "-")
// ValidationState::Acceptable   - Valid input

Step 7: Input Masks

For formatted input like phone numbers:

use horizon_lattice::widget::widgets::LineEdit;

// Phone number: (999) 999-9999
let mut phone = LineEdit::new();
phone.set_input_mask("(999) 999-9999");

// Date: YYYY-MM-DD
let mut date = LineEdit::new();
date.set_input_mask("0000-00-00");

// Time: HH:MM:SS
let mut time = LineEdit::new();
time.set_input_mask("99:99:99");

// License key (uppercase)
let mut license = LineEdit::new();
license.set_input_mask(">AAAAA-AAAAA-AAAAA");

Mask Characters

CharacterDescription
9Digit required (0-9)
0Digit optional
ALetter required (a-z, A-Z)
aLetter optional
NAlphanumeric required
nAlphanumeric optional
XAny character required
xAny character optional
>Uppercase following
<Lowercase following
\Escape next character

Complete Example: Contact Form

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{
    Label, LineEdit, SpinBox, CheckBox, ComboBox, PushButton,
    Container, Window, ButtonVariant
};
use horizon_lattice::widget::layout::{
    FormLayout, VBoxLayout, HBoxLayout, ContentMargins,
    FieldGrowthPolicy, Alignment, LayoutKind
};
use horizon_lattice::widget::validator::RegexValidator;
use horizon_lattice::model::StringListComboModel;
use std::sync::Arc;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("Contact Form")
        .with_size(450.0, 400.0);

    // --- Create form fields ---

    // Name (required)
    let name_label = Label::new("Name: *");
    let mut name_field = LineEdit::new();
    name_field.set_placeholder("Your full name");

    // Email (with validation)
    let email_label = Label::new("Email: *");
    let mut email_field = LineEdit::new();
    email_field.set_placeholder("user@example.com");
    email_field.set_validator(Arc::new(RegexValidator::new(
        r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
    )));

    // Phone (with mask)
    let phone_label = Label::new("Phone:");
    let mut phone_field = LineEdit::new();
    phone_field.set_input_mask("(999) 999-9999");

    // Age
    let age_label = Label::new("Age:");
    let age_field = SpinBox::new()
        .with_range(13, 120)
        .with_value(25);

    // Subject dropdown
    let subject_label = Label::new("Subject: *");
    let subjects = vec!["General Inquiry", "Support", "Feedback", "Other"];
    let subject_model = StringListComboModel::from(subjects);
    let mut subject_field = ComboBox::new()
        .with_model(Box::new(subject_model));
    subject_field.set_current_index(0);

    // Urgent checkbox
    let urgent_label = Label::new("Priority:");
    let urgent_field = CheckBox::new("Mark as urgent");

    // Newsletter checkbox
    let newsletter_label = Label::new("Updates:");
    let newsletter_field = CheckBox::new("Subscribe to newsletter")
        .with_checked(true);

    // --- Create form layout ---

    let mut form = FormLayout::new();
    form.set_label_alignment(Alignment::End);
    form.set_field_growth_policy(FieldGrowthPolicy::AllNonFixedFieldsGrow);
    form.set_horizontal_spacing(12.0);
    form.set_vertical_spacing(10.0);

    form.add_row(name_label.object_id(), name_field.object_id());
    form.add_row(email_label.object_id(), email_field.object_id());
    form.add_row(phone_label.object_id(), phone_field.object_id());
    form.add_row(age_label.object_id(), age_field.object_id());
    form.add_row(subject_label.object_id(), subject_field.object_id());
    form.add_row(urgent_label.object_id(), urgent_field.object_id());
    form.add_row(newsletter_label.object_id(), newsletter_field.object_id());

    // --- Create buttons ---

    let submit = PushButton::new("Submit")
        .with_default(true);
    let clear = PushButton::new("Clear")
        .with_variant(ButtonVariant::Secondary);
    let cancel = PushButton::new("Cancel")
        .with_variant(ButtonVariant::Flat);

    // --- Connect signals ---

    // Clone widgets for closures
    let name_clone = name_field.clone();
    let email_clone = email_field.clone();
    let phone_clone = phone_field.clone();
    let age_clone = age_field.clone();
    let subject_clone = subject_field.clone();
    let urgent_clone = urgent_field.clone();
    let newsletter_clone = newsletter_field.clone();

    submit.clicked().connect(move |_| {
        println!("=== Form Submitted ===");
        println!("Name: {}", name_clone.text());
        println!("Email: {}", email_clone.text());
        println!("Phone: {}", phone_clone.text());
        println!("Age: {}", age_clone.value());
        println!("Subject: {}", subject_clone.current_text());
        println!("Urgent: {}", urgent_clone.is_checked());
        println!("Newsletter: {}", newsletter_clone.is_checked());
    });

    // Clone for clear button
    let name_clear = name_field.clone();
    let email_clear = email_field.clone();
    let phone_clear = phone_field.clone();

    clear.clicked().connect(move |_| {
        name_clear.set_text("");
        email_clear.set_text("");
        phone_clear.set_text("");
    });

    cancel.clicked().connect(|_| {
        println!("Cancelled");
        Application::instance().quit();
    });

    // Email validation feedback
    email_field.input_rejected.connect(|| {
        println!("Invalid email format!");
    });

    // --- Button layout ---

    let mut button_row = HBoxLayout::new();
    button_row.set_spacing(10.0);
    button_row.add_stretch(1);  // Push buttons to right
    button_row.add_widget(cancel.object_id());
    button_row.add_widget(clear.object_id());
    button_row.add_widget(submit.object_id());

    let mut button_container = Container::new();
    button_container.set_layout(LayoutKind::from(button_row));

    // --- Main layout ---

    let mut main_layout = VBoxLayout::new();
    main_layout.set_content_margins(ContentMargins::uniform(20.0));
    main_layout.set_spacing(20.0);

    let mut form_container = Container::new();
    form_container.set_layout(LayoutKind::from(form));

    main_layout.add_widget(form_container.object_id());
    main_layout.add_widget(button_container.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(main_layout));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

Best Practices

  1. Use FormLayout for forms - Automatically handles label alignment
  2. Add placeholders - Help users understand expected input
  3. Validate early - Use validators to prevent invalid data entry
  4. Provide feedback - Connect to input_rejected to show validation errors
  5. Mark required fields - Use asterisks or other visual indicators
  6. Group related fields - Use nested layouts or separators
  7. Default sensible values - Pre-fill spinboxes and comboboxes
  8. Use appropriate widgets - SpinBox for numbers, ComboBox for fixed choices

Next Steps

Tutorial: Lists and Models

Learn the Model/View architecture for displaying collections of data.

What You’ll Learn

  • Understanding the Model/View pattern
  • Creating list models
  • Displaying data in ListView
  • Handling item selection
  • Dynamic item operations (add, remove, modify)

Prerequisites

  • Completed the Forms tutorial
  • Understanding of Rust traits

The Model/View Architecture

Horizon Lattice separates data (Model) from presentation (View):

  • Model: Holds the data and emits change signals
  • View: Displays the data and handles user interaction
  • Selection Model: Tracks which items are selected

This separation allows:

  • Multiple views of the same data
  • Efficient updates (only changed items redraw)
  • Reusable views with different data sources

Step 1: Using ListWidget (Simple Approach)

For simple lists, ListWidget manages its own data:

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{ListWidget, Window};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("Simple List")
        .with_size(300.0, 400.0);

    // Create list widget with items
    let mut list = ListWidget::new();
    list.add_item("Apple");
    list.add_item("Banana");
    list.add_item("Cherry");
    list.add_item("Date");
    list.add_item("Elderberry");

    // Handle item clicks
    list.item_clicked.connect(|&row| {
        println!("Clicked row: {}", row);
    });

    // Handle selection changes
    list.current_row_changed.connect(|(old, new)| {
        println!("Selection changed from {:?} to {:?}", old, new);
    });

    window.set_content_widget(list.object_id());
    window.show();

    app.run()
}

ListWidget Operations

use horizon_lattice::widget::widgets::ListWidget;

let mut list = ListWidget::new();

// Add items
list.add_item("Item 1");
list.add_item("Item 2");

// Insert at specific position
list.insert_item(1, "Inserted Item");

// Remove item
let removed = list.take_item(0);

// Clear all items
list.clear();

// Get current selection
let current_row = list.current_row();

// Set selection programmatically
list.set_current_row(Some(2));

// Get item count
let count = list.count();

Step 2: ListView with ListModel

For more control, use ListView with a separate ListModel:

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{ListView, Window};
use horizon_lattice::model::{ListModel, ListItem, ItemData};

// Define your data item
#[derive(Clone)]
struct Fruit {
    name: String,
    color: String,
}

// Implement ListItem to tell the model how to display it
impl ListItem for Fruit {
    fn display(&self) -> ItemData {
        ItemData::from(&self.name)
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("Fruit List")
        .with_size(300.0, 400.0);

    // Create model with data
    let model = ListModel::new(vec![
        Fruit { name: "Apple".into(), color: "Red".into() },
        Fruit { name: "Banana".into(), color: "Yellow".into() },
        Fruit { name: "Grape".into(), color: "Purple".into() },
    ]);

    // Create view with model
    let list_view = ListView::new()
        .with_model(model);

    // Handle clicks
    list_view.clicked.connect(|index| {
        println!("Clicked row: {}", index.row());
    });

    // Handle double-clicks
    list_view.double_clicked.connect(|index| {
        println!("Double-clicked row: {}", index.row());
    });

    window.set_content_widget(list_view.object_id());
    window.show();

    app.run()
}

Step 3: Custom Data Display

Use a closure-based extractor for complex display logic:

use horizon_lattice::model::{ListModel, ItemData, ItemRole};

#[derive(Clone)]
struct Contact {
    name: String,
    email: String,
    phone: String,
}

// Create model with custom data extraction
let model = ListModel::with_extractor(
    vec![
        Contact {
            name: "Alice".into(),
            email: "alice@example.com".into(),
            phone: "555-1234".into(),
        },
        Contact {
            name: "Bob".into(),
            email: "bob@example.com".into(),
            phone: "555-5678".into(),
        },
    ],
    |contact, role| match role {
        ItemRole::Display => ItemData::from(&contact.name),
        ItemRole::ToolTip => ItemData::from(format!("{}\n{}", contact.email, contact.phone)),
        _ => ItemData::None,
    },
);

Item Roles

Different roles provide different aspects of item data:

RolePurpose
DisplayMain text to show
DecorationIcon or image
ToolTipHover tooltip text
EditValue for editing
CheckStateCheckbox state
BackgroundColorBackground color
ForegroundColorText color
FontCustom font

Step 4: Selection Handling

Control how users select items:

use horizon_lattice::widget::widgets::ListView;
use horizon_lattice::model::{SelectionMode, SelectionFlags};

let mut list_view = ListView::new()
    .with_model(model)
    .with_selection_mode(SelectionMode::ExtendedSelection);

// Selection modes:
// - NoSelection: Nothing selectable
// - SingleSelection: One item at a time
// - MultiSelection: Ctrl+click for multiple
// - ExtendedSelection: Shift+click for ranges + Ctrl+click

// Get the selection model
let selection = list_view.selection_model();

// Listen for selection changes
selection.selection_changed.connect(|(selected, deselected)| {
    println!("Selection changed!");
    for idx in &selected {
        println!("  Selected: row {}", idx.row());
    }
    for idx in &deselected {
        println!("  Deselected: row {}", idx.row());
    }
});

// Listen for current item changes
selection.current_changed.connect(|(new_index, old_index)| {
    println!("Current changed from {:?} to {:?}",
        old_index.map(|i| i.row()),
        new_index.map(|i| i.row())
    );
});

Programmatic Selection

use horizon_lattice::model::{ModelIndex, SelectionFlags};

let selection = list_view.selection_model();

// Select a single item
let index = ModelIndex::new(2, 0);  // Row 2, Column 0
selection.select(index, SelectionFlags::CLEAR_SELECT_CURRENT);

// Select a range
selection.select_range(0, 4, SelectionFlags::CLEAR_AND_SELECT);

// Get selected items
let selected_indices = selection.selected_indices();
let selected_rows = selection.selected_rows();

// Clear selection
selection.clear_selection();

// Check if index is selected
let is_selected = selection.is_selected(index);

Step 5: Dynamic List Operations

Add, remove, and modify items dynamically:

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{
    ListView, PushButton, LineEdit, Container, Window
};
use horizon_lattice::widget::layout::{VBoxLayout, HBoxLayout, LayoutKind};
use horizon_lattice::model::ListModel;
use std::sync::{Arc, Mutex};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("Dynamic List")
        .with_size(400.0, 500.0);

    // Shared model (wrapped in Arc<Mutex> for thread-safe access)
    let model = Arc::new(Mutex::new(ListModel::new(vec![
        "Item 1".to_string(),
        "Item 2".to_string(),
        "Item 3".to_string(),
    ])));

    // Create view
    let list_view = ListView::new()
        .with_model(model.lock().unwrap().clone());

    // Input field for new items
    let mut input = LineEdit::new();
    input.set_placeholder("Enter new item...");

    // Buttons
    let add_btn = PushButton::new("Add");
    let remove_btn = PushButton::new("Remove Selected");
    let clear_btn = PushButton::new("Clear All");

    // Connect Add button
    let model_clone = model.clone();
    let input_clone = input.clone();
    add_btn.clicked().connect(move |_| {
        let text = input_clone.text();
        if !text.is_empty() {
            model_clone.lock().unwrap().push(text.clone());
            input_clone.set_text("");
        }
    });

    // Connect Remove button
    let model_clone = model.clone();
    let list_clone = list_view.clone();
    remove_btn.clicked().connect(move |_| {
        let selection = list_clone.selection_model();
        let mut rows: Vec<usize> = selection.selected_rows();
        // Remove from highest to lowest to avoid index shifting
        rows.sort_by(|a, b| b.cmp(a));
        for row in rows {
            model_clone.lock().unwrap().remove(row);
        }
    });

    // Connect Clear button
    let model_clone = model.clone();
    clear_btn.clicked().connect(move |_| {
        model_clone.lock().unwrap().clear();
    });

    // Layout
    let mut button_row = HBoxLayout::new();
    button_row.set_spacing(8.0);
    button_row.add_widget(add_btn.object_id());
    button_row.add_widget(remove_btn.object_id());
    button_row.add_widget(clear_btn.object_id());

    let mut button_container = Container::new();
    button_container.set_layout(LayoutKind::from(button_row));

    let mut main_layout = VBoxLayout::new();
    main_layout.set_spacing(10.0);
    main_layout.add_widget(input.object_id());
    main_layout.add_widget(button_container.object_id());
    main_layout.add_widget(list_view.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(main_layout));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

Model Operations

use horizon_lattice::model::ListModel;

let mut model = ListModel::new(vec!["A", "B", "C"]);

// Add items
model.push("D");                    // Append at end
model.insert(1, "Inserted");        // Insert at position

// Remove items
let item = model.remove(0);         // Remove and return item
model.clear();                      // Remove all

// Replace all items
model.set_items(vec!["X", "Y", "Z"]);

// Modify an item in place
model.modify(0, |item| {
    *item = "Modified".to_string();
});

// Sort items
model.sort_by(|a, b| a.cmp(b));

// Query
let count = model.len();
let is_empty = model.is_empty();

Step 6: View Modes

ListView supports different display modes:

use horizon_lattice::widget::widgets::{ListView, ListViewMode};

// List mode (vertical list, one item per row)
let list = ListView::new()
    .with_view_mode(ListViewMode::ListMode)
    .with_model(model.clone());

// Icon mode (grid of items)
let grid = ListView::new()
    .with_view_mode(ListViewMode::IconMode)
    .with_model(model);

Complete Example: Task Manager

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{
    ListView, PushButton, LineEdit, CheckBox, Label,
    Container, Window, ButtonVariant
};
use horizon_lattice::widget::layout::{
    VBoxLayout, HBoxLayout, ContentMargins, LayoutKind
};
use horizon_lattice::model::{
    ListModel, ListItem, ItemData, ItemRole, SelectionMode
};
use std::sync::{Arc, Mutex};

#[derive(Clone)]
struct Task {
    title: String,
    completed: bool,
}

impl ListItem for Task {
    fn display(&self) -> ItemData {
        let prefix = if self.completed { "[x]" } else { "[ ]" };
        ItemData::from(format!("{} {}", prefix, self.title))
    }

    fn data(&self, role: ItemRole) -> ItemData {
        match role {
            ItemRole::Display => self.display(),
            ItemRole::CheckState => {
                if self.completed {
                    ItemData::from(true)
                } else {
                    ItemData::from(false)
                }
            }
            _ => ItemData::None,
        }
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("Task Manager")
        .with_size(400.0, 500.0);

    // Model with initial tasks
    let model = Arc::new(Mutex::new(ListModel::new(vec![
        Task { title: "Buy groceries".into(), completed: false },
        Task { title: "Walk the dog".into(), completed: true },
        Task { title: "Read a book".into(), completed: false },
    ])));

    // Views and controls
    let list_view = ListView::new()
        .with_model(model.lock().unwrap().clone())
        .with_selection_mode(SelectionMode::SingleSelection);

    let mut task_input = LineEdit::new();
    task_input.set_placeholder("New task...");

    let add_btn = PushButton::new("Add Task");
    let toggle_btn = PushButton::new("Toggle Done")
        .with_variant(ButtonVariant::Secondary);
    let delete_btn = PushButton::new("Delete")
        .with_variant(ButtonVariant::Danger);

    let title = Label::new("My Tasks");

    // Add task
    let model_clone = model.clone();
    let input_clone = task_input.clone();
    add_btn.clicked().connect(move |_| {
        let text = input_clone.text();
        if !text.is_empty() {
            model_clone.lock().unwrap().push(Task {
                title: text.clone(),
                completed: false,
            });
            input_clone.set_text("");
        }
    });

    // Toggle completion
    let model_clone = model.clone();
    let list_clone = list_view.clone();
    toggle_btn.clicked().connect(move |_| {
        let selection = list_clone.selection_model();
        if let Some(index) = selection.current_index() {
            let row = index.row() as usize;
            model_clone.lock().unwrap().modify(row, |task| {
                task.completed = !task.completed;
            });
        }
    });

    // Delete task
    let model_clone = model.clone();
    let list_clone = list_view.clone();
    delete_btn.clicked().connect(move |_| {
        let selection = list_clone.selection_model();
        if let Some(index) = selection.current_index() {
            model_clone.lock().unwrap().remove(index.row() as usize);
        }
    });

    // Enter key adds task
    let model_clone = model.clone();
    let input_clone = task_input.clone();
    task_input.return_pressed.connect(move || {
        let text = input_clone.text();
        if !text.is_empty() {
            model_clone.lock().unwrap().push(Task {
                title: text.clone(),
                completed: false,
            });
            input_clone.set_text("");
        }
    });

    // Layout
    let mut input_row = HBoxLayout::new();
    input_row.set_spacing(8.0);
    input_row.add_widget(task_input.object_id());
    input_row.add_widget(add_btn.object_id());

    let mut input_container = Container::new();
    input_container.set_layout(LayoutKind::from(input_row));

    let mut action_row = HBoxLayout::new();
    action_row.set_spacing(8.0);
    action_row.add_stretch(1);
    action_row.add_widget(toggle_btn.object_id());
    action_row.add_widget(delete_btn.object_id());

    let mut action_container = Container::new();
    action_container.set_layout(LayoutKind::from(action_row));

    let mut main_layout = VBoxLayout::new();
    main_layout.set_content_margins(ContentMargins::uniform(16.0));
    main_layout.set_spacing(12.0);
    main_layout.add_widget(title.object_id());
    main_layout.add_widget(input_container.object_id());
    main_layout.add_widget(list_view.object_id());
    main_layout.add_widget(action_container.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(main_layout));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

Best Practices

  1. Use ListWidget for simple cases - When you don’t need complex data binding
  2. Use ListView + ListModel for structured data - Better separation of concerns
  3. Implement ListItem for custom types - Clean data display logic
  4. Handle selection appropriately - Use the right SelectionMode for your use case
  5. Remove from highest to lowest index - Prevents index shifting issues
  6. Use Arc<Mutex<>> for shared model access - Thread-safe model updates

Next Steps

Tutorial: Custom Widgets

Learn to create your own widgets with custom painting and event handling.

What You’ll Learn

  • Implementing the Widget trait
  • Creating custom painting with PaintContext
  • Handling mouse and keyboard events
  • Managing widget state and focus
  • Emitting custom signals

Prerequisites

  • Completed the Lists tutorial
  • Understanding of Rust traits and structs
  • Basic familiarity with the Widget system from the Widget Guide

The Widget Architecture

Custom widgets in Horizon Lattice require implementing two traits:

  1. Object - Provides unique identification via object_id()
  2. Widget - Provides UI behavior: size hints, painting, event handling

Every widget contains a WidgetBase that handles common functionality like geometry, visibility, focus, and state tracking.

Step 1: A Minimal Custom Widget

Let’s create a simple ColorBox widget that displays a solid color:

use horizon_lattice::widget::{Widget, WidgetBase, SizeHint, PaintContext};
use horizon_lattice::render::Color;
use horizon_lattice_core::{Object, ObjectId};

/// A simple widget that displays a solid color.
pub struct ColorBox {
    base: WidgetBase,
    color: Color,
}

impl ColorBox {
    /// Create a new ColorBox with the specified color.
    pub fn new(color: Color) -> Self {
        Self {
            base: WidgetBase::new::<Self>(),
            color,
        }
    }

    /// Get the current color.
    pub fn color(&self) -> Color {
        self.color
    }

    /// Set the color and trigger a repaint.
    pub fn set_color(&mut self, color: Color) {
        if self.color != color {
            self.color = color;
            self.base.update(); // Schedule repaint
        }
    }
}

// Implement Object trait for identification
impl Object for ColorBox {
    fn object_id(&self) -> ObjectId {
        self.base.object_id()
    }
}

// Implement Widget trait for UI behavior
impl Widget for ColorBox {
    fn widget_base(&self) -> &WidgetBase {
        &self.base
    }

    fn widget_base_mut(&mut self) -> &mut WidgetBase {
        &mut self.base
    }

    fn size_hint(&self) -> SizeHint {
        // Preferred 100x100, minimum 20x20
        SizeHint::from_dimensions(100.0, 100.0)
            .with_minimum_dimensions(20.0, 20.0)
    }

    fn paint(&self, ctx: &mut PaintContext<'_>) {
        // Fill the entire widget with our color
        ctx.renderer().fill_rect(ctx.rect(), self.color);
    }
}

Using ColorBox

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::Window;
use horizon_lattice::render::Color;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("Color Box")
        .with_size(300.0, 200.0);

    let color_box = ColorBox::new(Color::from_rgb8(65, 105, 225)); // Royal Blue

    window.set_content_widget(color_box.object_id());
    window.show();

    app.run()
}

Step 2: Understanding WidgetBase

WidgetBase provides essential functionality that all widgets need:

Geometry

// Get widget bounds
let rect = self.base.geometry();      // Position + size in parent coordinates
let size = self.base.size();
let pos = self.base.pos();

// Set geometry (usually done by layout)
self.base.set_geometry(Rect::new(Point::new(10.0, 10.0), Size::new(100.0, 50.0)));

Visibility

// Show/hide
self.base.show();
self.base.hide();
self.base.set_visible(true);

// Check visibility
let visible = self.base.is_visible();
let effective = self.base.is_effectively_visible(); // Considers ancestors

Enabled State

// Enable/disable
self.base.enable();
self.base.disable();
self.base.set_enabled(false);

// Check state
let enabled = self.base.is_enabled();
let effective = self.base.is_effectively_enabled(); // Considers ancestors

Repaint Scheduling

// Schedule repaint for next frame
self.base.update();

// Schedule partial repaint
self.base.update_rect(Rect::new(Point::new(0.0, 0.0), Size::new(50.0, 50.0)));

Step 3: Custom Painting

The paint() method receives a PaintContext that provides access to the renderer:

fn paint(&self, ctx: &mut PaintContext<'_>) {
    let renderer = ctx.renderer();
    let rect = ctx.rect(); // Widget bounds (0,0 to width,height)

    // Fill background
    renderer.fill_rect(rect, self.background_color);

    // Draw border
    let stroke = Stroke::new(self.border_color, 2.0);
    renderer.stroke_rect(rect, &stroke);

    // Draw text
    renderer.draw_text(
        Point::new(10.0, 10.0),
        &self.text,
        &self.font,
        self.text_color,
    );

    // Draw focus indicator if focused
    if ctx.should_show_focus() {
        ctx.draw_focus_indicator(2.0);
    }
}

Coordinate System

  • The renderer is pre-translated so (0, 0) is the widget’s top-left corner
  • Use ctx.rect() to get the full widget bounds in local coordinates
  • ctx.width() and ctx.height() provide dimensions directly

PaintContext Methods

MethodPurpose
renderer()Get the GpuRenderer for drawing
rect()Widget bounds (local coordinates)
width() / height()Widget dimensions
size()Widget size as Size struct
is_alt_held()Check if Alt key is pressed (for mnemonics)
should_show_focus()Check if focus indicator should be drawn
draw_focus_indicator(inset)Draw standard focus ring

Step 4: Event Handling

Override the event() method to handle user input:

use horizon_lattice::widget::{
    Widget, WidgetBase, SizeHint, PaintContext,
    WidgetEvent, MousePressEvent, MouseReleaseEvent, MouseButton
};

impl Widget for MyWidget {
    // ... other methods ...

    fn event(&mut self, event: &mut WidgetEvent) -> bool {
        match event {
            WidgetEvent::MousePress(e) => {
                if e.button == MouseButton::Left {
                    // Handle left click
                    e.base.accept(); // Mark event as handled
                    return true;
                }
            }
            WidgetEvent::MouseRelease(e) => {
                if e.button == MouseButton::Left {
                    // Handle release
                    e.base.accept();
                    return true;
                }
            }
            _ => {}
        }
        false // Event not handled
    }
}

Event Types

EventWhen Triggered
MousePressMouse button pressed
MouseReleaseMouse button released
MouseMoveMouse moved over widget
MouseDoubleClickDouble-click detected
EnterMouse enters widget bounds
LeaveMouse leaves widget bounds
WheelScroll wheel moved
KeyPressKey pressed while focused
KeyReleaseKey released while focused
FocusInWidget gained focus
FocusOutWidget lost focus
ResizeWidget size changed
MoveWidget position changed

Mouse Event Data

WidgetEvent::MousePress(e) => {
    let button = e.button;        // MouseButton::Left, Right, Middle
    let local = e.local_pos;      // Position in widget coordinates
    let window = e.window_pos;    // Position in window coordinates
    let global = e.global_pos;    // Position in screen coordinates
    let mods = e.modifiers;       // KeyboardModifiers { shift, control, alt, meta }
}

Keyboard Event Data

WidgetEvent::KeyPress(e) => {
    let key = e.key;              // Key enum (Key::A, Key::Space, etc.)
    let text = &e.text;           // Character(s) typed (for text input)
    let mods = e.modifiers;       // Modifier keys held
    let repeat = e.repeat;        // Is this an auto-repeat?
}

Step 5: Focus Management

To receive keyboard events, widgets must accept focus:

use horizon_lattice::widget::FocusPolicy;

impl MyWidget {
    pub fn new() -> Self {
        let mut base = WidgetBase::new::<Self>();
        // Accept focus from both Tab and mouse click
        base.set_focus_policy(FocusPolicy::StrongFocus);
        Self { base, /* ... */ }
    }
}

Focus Policies

PolicyTab FocusClick Focus
NoFocusNoNo
TabFocusYesNo
ClickFocusNoYes
StrongFocusYesYes

Focus in Paint

fn paint(&self, ctx: &mut PaintContext<'_>) {
    // Paint normal content...

    // Show focus indicator when focused
    if ctx.should_show_focus() && self.base.has_focus() {
        ctx.draw_focus_indicator(2.0); // 2px inset from edge
    }
}

Step 6: Widget State

WidgetBase tracks common state automatically:

// In paint or event handlers:
let is_pressed = self.base.is_pressed();   // Mouse button held down
let is_hovered = self.base.is_hovered();   // Mouse over widget
let has_focus = self.base.has_focus();     // Widget has keyboard focus

Use this state for visual feedback:

fn paint(&self, ctx: &mut PaintContext<'_>) {
    // Choose color based on state
    let bg_color = if self.base.is_pressed() {
        Color::from_rgb8(45, 85, 205)  // Darker when pressed
    } else if self.base.is_hovered() {
        Color::from_rgb8(85, 145, 255)  // Lighter when hovered
    } else {
        Color::from_rgb8(65, 105, 225)  // Normal
    };

    ctx.renderer().fill_rect(ctx.rect(), bg_color);
}

Step 7: Custom Signals

Use signals to notify external code of events:

use horizon_lattice_core::Signal;

pub struct ClickCounter {
    base: WidgetBase,
    count: u32,

    // Custom signals
    pub clicked: Signal<()>,
    pub count_changed: Signal<u32>,
}

impl ClickCounter {
    pub fn new() -> Self {
        Self {
            base: WidgetBase::new::<Self>(),
            count: 0,
            clicked: Signal::new(),
            count_changed: Signal::new(),
        }
    }

    fn increment(&mut self) {
        self.count += 1;
        self.clicked.emit(());
        self.count_changed.emit(self.count);
        self.base.update(); // Repaint to show new count
    }
}

impl Widget for ClickCounter {
    // ... base methods ...

    fn event(&mut self, event: &mut WidgetEvent) -> bool {
        match event {
            WidgetEvent::MouseRelease(e) => {
                if e.button == MouseButton::Left && self.base.is_pressed() {
                    self.increment();
                    e.base.accept();
                    return true;
                }
            }
            _ => {}
        }
        false
    }
}

Connecting to Signals

let counter = ClickCounter::new();

counter.clicked.connect(|_| {
    println!("Counter was clicked!");
});

counter.count_changed.connect(|&count| {
    println!("Count is now: {}", count);
});

Complete Example: Interactive Slider

Here’s a complete custom slider widget:

use horizon_lattice::Application;
use horizon_lattice::widget::{
    Widget, WidgetBase, SizeHint, PaintContext, FocusPolicy,
    WidgetEvent, MouseButton
};
use horizon_lattice::widget::widgets::{Window, Label, Container};
use horizon_lattice::widget::layout::{VBoxLayout, LayoutKind};
use horizon_lattice::render::{Color, Point, Rect, Size, Stroke};
use horizon_lattice_core::{Object, ObjectId, Signal};
use std::sync::Arc;

/// A custom horizontal slider widget.
pub struct Slider {
    base: WidgetBase,
    value: f32,          // 0.0 to 1.0
    dragging: bool,
    track_color: Color,
    thumb_color: Color,
    thumb_hover_color: Color,

    /// Emitted when the value changes.
    pub value_changed: Signal<f32>,
}

impl Slider {
    const THUMB_WIDTH: f32 = 16.0;
    const THUMB_HEIGHT: f32 = 24.0;
    const TRACK_HEIGHT: f32 = 4.0;

    pub fn new() -> Self {
        let mut base = WidgetBase::new::<Self>();
        base.set_focus_policy(FocusPolicy::StrongFocus);

        Self {
            base,
            value: 0.0,
            dragging: false,
            track_color: Color::from_rgb8(200, 200, 200),
            thumb_color: Color::from_rgb8(65, 105, 225),
            thumb_hover_color: Color::from_rgb8(85, 125, 245),
            value_changed: Signal::new(),
        }
    }

    pub fn value(&self) -> f32 {
        self.value
    }

    pub fn set_value(&mut self, value: f32) {
        let clamped = value.clamp(0.0, 1.0);
        if (self.value - clamped).abs() > f32::EPSILON {
            self.value = clamped;
            self.value_changed.emit(self.value);
            self.base.update();
        }
    }

    fn value_from_x(&self, x: f32) -> f32 {
        let usable_width = self.base.width() - Self::THUMB_WIDTH;
        if usable_width <= 0.0 {
            return 0.0;
        }
        let thumb_center_x = x - Self::THUMB_WIDTH / 2.0;
        (thumb_center_x / usable_width).clamp(0.0, 1.0)
    }

    fn thumb_rect(&self) -> Rect {
        let usable_width = self.base.width() - Self::THUMB_WIDTH;
        let thumb_x = self.value * usable_width;
        let thumb_y = (self.base.height() - Self::THUMB_HEIGHT) / 2.0;
        Rect::new(
            Point::new(thumb_x, thumb_y),
            Size::new(Self::THUMB_WIDTH, Self::THUMB_HEIGHT),
        )
    }
}

impl Object for Slider {
    fn object_id(&self) -> ObjectId {
        self.base.object_id()
    }
}

impl Widget for Slider {
    fn widget_base(&self) -> &WidgetBase {
        &self.base
    }

    fn widget_base_mut(&mut self) -> &mut WidgetBase {
        &mut self.base
    }

    fn size_hint(&self) -> SizeHint {
        SizeHint::from_dimensions(200.0, 30.0)
            .with_minimum_dimensions(50.0, 24.0)
    }

    fn paint(&self, ctx: &mut PaintContext<'_>) {
        let width = ctx.width();
        let height = ctx.height();

        // Draw track
        let track_y = (height - Self::TRACK_HEIGHT) / 2.0;
        let track_rect = Rect::new(
            Point::new(Self::THUMB_WIDTH / 2.0, track_y),
            Size::new(width - Self::THUMB_WIDTH, Self::TRACK_HEIGHT),
        );
        ctx.renderer().fill_rect(track_rect, self.track_color);

        // Draw filled portion of track
        let filled_width = self.value * (width - Self::THUMB_WIDTH);
        let filled_rect = Rect::new(
            Point::new(Self::THUMB_WIDTH / 2.0, track_y),
            Size::new(filled_width, Self::TRACK_HEIGHT),
        );
        ctx.renderer().fill_rect(filled_rect, self.thumb_color);

        // Draw thumb
        let thumb_rect = self.thumb_rect();
        let thumb_color = if self.dragging || self.base.is_hovered() {
            self.thumb_hover_color
        } else {
            self.thumb_color
        };
        ctx.renderer().fill_rounded_rect(thumb_rect, 4.0, thumb_color);

        // Draw focus indicator around thumb
        if ctx.should_show_focus() {
            let focus_rect = thumb_rect.inflate(2.0, 2.0);
            let stroke = Stroke::new(Color::from_rgb8(0, 120, 212), 2.0);
            ctx.renderer().stroke_rounded_rect(focus_rect, 6.0, &stroke);
        }
    }

    fn event(&mut self, event: &mut WidgetEvent) -> bool {
        match event {
            WidgetEvent::MousePress(e) => {
                if e.button == MouseButton::Left {
                    self.dragging = true;
                    self.set_value(self.value_from_x(e.local_pos.x));
                    e.base.accept();
                    return true;
                }
            }
            WidgetEvent::MouseRelease(e) => {
                if e.button == MouseButton::Left && self.dragging {
                    self.dragging = false;
                    self.base.update();
                    e.base.accept();
                    return true;
                }
            }
            WidgetEvent::MouseMove(e) => {
                if self.dragging {
                    self.set_value(self.value_from_x(e.local_pos.x));
                    e.base.accept();
                    return true;
                }
            }
            WidgetEvent::KeyPress(e) => {
                use horizon_lattice::widget::Key;
                match e.key {
                    Key::ArrowLeft => {
                        self.set_value(self.value - 0.05);
                        e.base.accept();
                        return true;
                    }
                    Key::ArrowRight => {
                        self.set_value(self.value + 0.05);
                        e.base.accept();
                        return true;
                    }
                    Key::Home => {
                        self.set_value(0.0);
                        e.base.accept();
                        return true;
                    }
                    Key::End => {
                        self.set_value(1.0);
                        e.base.accept();
                        return true;
                    }
                    _ => {}
                }
            }
            _ => {}
        }
        false
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("Custom Slider")
        .with_size(400.0, 150.0);

    // Create our custom slider
    let slider = Arc::new(std::sync::Mutex::new(Slider::new()));

    // Create label to show value
    let label = Label::new("Value: 0%");

    // Connect slider to label
    let label_clone = label.clone();
    slider.lock().unwrap().value_changed.connect(move |&value| {
        label_clone.set_text(&format!("Value: {:.0}%", value * 100.0));
    });

    // Layout
    let mut layout = VBoxLayout::new();
    layout.set_spacing(20.0);
    layout.add_widget(slider.lock().unwrap().object_id());
    layout.add_widget(label.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(layout));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

Best Practices

1. Always Use WidgetBase

Never create widget state that duplicates what WidgetBase already provides:

// Bad - duplicating state
struct MyWidget {
    base: WidgetBase,
    visible: bool,  // Already in WidgetBase!
    position: Point, // Already in WidgetBase!
}

// Good - use WidgetBase
struct MyWidget {
    base: WidgetBase,
    custom_state: String, // Only add unique state
}

2. Call update() When State Changes

Always schedule a repaint when visual state changes:

pub fn set_color(&mut self, color: Color) {
    if self.color != color {
        self.color = color;
        self.base.update(); // Don't forget this!
    }
}

3. Accept Events You Handle

Mark events as accepted to prevent propagation:

fn event(&mut self, event: &mut WidgetEvent) -> bool {
    match event {
        WidgetEvent::MousePress(e) => {
            e.base.accept(); // Important!
            return true;
        }
        _ => {}
    }
    false
}

4. Set Appropriate Focus Policy

Choose the right policy for your widget type:

  • NoFocus: Decorative widgets (labels, separators)
  • ClickFocus: Mouse-primary widgets (list items)
  • TabFocus: Keyboard-primary widgets (rare)
  • StrongFocus: Interactive widgets (buttons, sliders, inputs)

5. Provide Meaningful Size Hints

Help layouts by providing accurate size information:

fn size_hint(&self) -> SizeHint {
    SizeHint::from_dimensions(200.0, 40.0)  // Preferred
        .with_minimum_dimensions(100.0, 30.0)  // Minimum usable
        .with_maximum_dimensions(500.0, 40.0)  // Maximum reasonable
}

6. Handle Both Mouse and Keyboard

Make widgets accessible by supporting keyboard navigation:

fn event(&mut self, event: &mut WidgetEvent) -> bool {
    match event {
        // Mouse activation
        WidgetEvent::MouseRelease(e) if e.button == MouseButton::Left => {
            self.activate();
            e.base.accept();
            true
        }
        // Keyboard activation (Space or Enter)
        WidgetEvent::KeyPress(e) if e.key == Key::Space || e.key == Key::Enter => {
            self.activate();
            e.base.accept();
            true
        }
        _ => false,
    }
}

Next Steps

Tutorial: Theming

Learn to style your application with themes and switch between light and dark modes.

What You’ll Learn

  • Understanding the theme system
  • Applying built-in themes
  • Creating custom themes
  • Switching themes at runtime
  • Detecting and following system dark mode
  • Styling individual widgets

Prerequisites

The Theme System

Horizon Lattice uses a comprehensive theming system inspired by Material Design:

  • Theme - Defines colors, typography, and widget defaults
  • ColorPalette - The color scheme (primary, secondary, background, etc.)
  • StyleEngine - Resolves and applies styles to widgets
  • ThemeMode - Light, Dark, or High Contrast

Step 1: Built-in Themes

Horizon Lattice provides three built-in themes:

use horizon_lattice_style::{Theme, ThemeMode};

// Light theme (default)
let light = Theme::light();

// Dark theme
let dark = Theme::dark();

// High contrast theme (accessibility)
let high_contrast = Theme::high_contrast();

Using a Theme with StyleEngine

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{Label, PushButton, Container, Window};
use horizon_lattice::widget::layout::{VBoxLayout, LayoutKind};
use horizon_lattice_style::{StyleEngine, Theme};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    // Create style engine with dark theme
    let style_engine = StyleEngine::dark();
    app.set_style_engine(style_engine);

    let mut window = Window::new("Dark Theme App")
        .with_size(400.0, 300.0);

    let label = Label::new("Welcome to the dark side!");
    let button = PushButton::new("Click me");

    let mut layout = VBoxLayout::new();
    layout.add_widget(label.object_id());
    layout.add_widget(button.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(layout));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

Step 2: Understanding ColorPalette

The ColorPalette defines all colors used throughout the theme:

use horizon_lattice_style::ColorPalette;
use horizon_lattice::render::Color;

// Get the light palette
let palette = ColorPalette::light();

// Access specific colors
let primary = palette.primary;           // Main brand color
let background = palette.background;     // App background
let text = palette.text_primary;         // Primary text color
let error = palette.error;               // Error/danger color

Color Categories

CategoryColorsPurpose
Primaryprimary, primary_light, primary_dark, on_primaryBrand/accent colors
Secondarysecondary, secondary_light, secondary_dark, on_secondaryComplementary accent
Backgroundbackground, surface, surface_variantContainer backgrounds
Texttext_primary, text_secondary, text_disabledText colors
Semanticerror, warning, success, infoStatus indicators
Bordersborder, border_light, dividerLines and separators

Light vs Dark Palette Colors

// Light palette
let light = ColorPalette::light();
assert_eq!(light.background, Color::from_rgb8(255, 255, 255)); // White
assert_eq!(light.text_primary, Color::from_rgb8(33, 33, 33));  // Near black

// Dark palette
let dark = ColorPalette::dark();
assert_eq!(dark.background, Color::from_rgb8(18, 18, 18));     // Near black
assert_eq!(dark.text_primary, Color::from_rgb8(255, 255, 255)); // White

Step 3: Creating Custom Themes

Create a custom theme with your own color palette:

use horizon_lattice_style::{Theme, ThemeMode, ColorPalette};
use horizon_lattice::render::Color;

// Start with a base palette and customize
fn create_brand_theme() -> Theme {
    let mut palette = ColorPalette::light();

    // Set brand colors
    palette.primary = Color::from_hex("#6200EE").unwrap();       // Purple
    palette.primary_light = Color::from_hex("#9D46FF").unwrap();
    palette.primary_dark = Color::from_hex("#3700B3").unwrap();
    palette.on_primary = Color::WHITE;

    palette.secondary = Color::from_hex("#03DAC6").unwrap();     // Teal
    palette.secondary_light = Color::from_hex("#66FFF8").unwrap();
    palette.secondary_dark = Color::from_hex("#00A896").unwrap();
    palette.on_secondary = Color::BLACK;

    // Create theme from custom palette
    Theme::custom(ThemeMode::Light, palette)
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let brand_theme = create_brand_theme();
    let style_engine = StyleEngine::new(brand_theme);
    app.set_style_engine(style_engine);

    // ... rest of app setup
    Ok(())
}

Creating a Complete Dark Brand Theme

fn create_dark_brand_theme() -> Theme {
    let mut palette = ColorPalette::dark();

    // Adjust primary for dark backgrounds
    palette.primary = Color::from_hex("#BB86FC").unwrap();       // Light purple
    palette.primary_light = Color::from_hex("#E4B8FF").unwrap();
    palette.primary_dark = Color::from_hex("#8858C8").unwrap();
    palette.on_primary = Color::BLACK;

    palette.secondary = Color::from_hex("#03DAC6").unwrap();     // Teal
    palette.on_secondary = Color::BLACK;

    // Adjust backgrounds for OLED-friendly dark
    palette.background = Color::from_rgb8(0, 0, 0);              // Pure black
    palette.surface = Color::from_rgb8(30, 30, 30);
    palette.surface_variant = Color::from_rgb8(45, 45, 45);

    Theme::custom(ThemeMode::Dark, palette)
}

Step 4: Switching Themes at Runtime

Switch between themes dynamically:

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{
    PushButton, Label, Container, Window, ButtonVariant
};
use horizon_lattice::widget::layout::{VBoxLayout, HBoxLayout, LayoutKind};
use horizon_lattice_style::{StyleEngine, Theme};
use std::sync::{Arc, RwLock};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    // Wrap style engine for shared access
    let style_engine = Arc::new(RwLock::new(StyleEngine::light()));
    app.set_shared_style_engine(style_engine.clone());

    let mut window = Window::new("Theme Switcher")
        .with_size(400.0, 300.0);

    let label = Label::new("Current theme: Light");

    // Theme buttons
    let light_btn = PushButton::new("Light Theme");
    let dark_btn = PushButton::new("Dark Theme")
        .with_variant(ButtonVariant::Secondary);
    let contrast_btn = PushButton::new("High Contrast")
        .with_variant(ButtonVariant::Outlined);

    // Light theme button
    let engine = style_engine.clone();
    let label_clone = label.clone();
    light_btn.clicked().connect(move |_| {
        let mut eng = engine.write().unwrap();
        eng.set_theme(Theme::light());
        eng.invalidate_all(); // Refresh all widget styles
        label_clone.set_text("Current theme: Light");
    });

    // Dark theme button
    let engine = style_engine.clone();
    let label_clone = label.clone();
    dark_btn.clicked().connect(move |_| {
        let mut eng = engine.write().unwrap();
        eng.set_theme(Theme::dark());
        eng.invalidate_all();
        label_clone.set_text("Current theme: Dark");
    });

    // High contrast button
    let engine = style_engine.clone();
    let label_clone = label.clone();
    contrast_btn.clicked().connect(move |_| {
        let mut eng = engine.write().unwrap();
        eng.set_theme(Theme::high_contrast());
        eng.invalidate_all();
        label_clone.set_text("Current theme: High Contrast");
    });

    // Button row
    let mut button_row = HBoxLayout::new();
    button_row.set_spacing(8.0);
    button_row.add_widget(light_btn.object_id());
    button_row.add_widget(dark_btn.object_id());
    button_row.add_widget(contrast_btn.object_id());

    let mut button_container = Container::new();
    button_container.set_layout(LayoutKind::from(button_row));

    // Main layout
    let mut layout = VBoxLayout::new();
    layout.set_spacing(20.0);
    layout.add_widget(label.object_id());
    layout.add_widget(button_container.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(layout));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

Step 5: Following System Dark Mode

Automatically follow the system’s dark mode setting:

use horizon_lattice::Application;
use horizon_lattice::platform::{SystemTheme, ColorScheme, ThemeWatcher, ThemeAutoUpdater};
use horizon_lattice_style::{StyleEngine, Theme};
use std::sync::{Arc, RwLock};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    // Detect initial system theme
    let initial_theme = match SystemTheme::color_scheme() {
        ColorScheme::Dark => Theme::dark(),
        _ => Theme::light(),
    };

    let style_engine = Arc::new(RwLock::new(StyleEngine::new(initial_theme)));
    app.set_shared_style_engine(style_engine.clone());

    // Set up automatic theme updates
    let watcher = ThemeWatcher::new()?;
    let auto_updater = ThemeAutoUpdater::new(watcher, style_engine.clone());
    auto_updater.start()?;

    // ... rest of app setup

    app.run()
}

Manual Theme Watching

For more control, handle theme changes manually:

use horizon_lattice::platform::{ThemeWatcher, ColorScheme};
use horizon_lattice_style::{StyleEngine, Theme};
use std::sync::{Arc, RwLock};

fn setup_theme_watcher(
    style_engine: Arc<RwLock<StyleEngine>>,
) -> Result<ThemeWatcher, Box<dyn std::error::Error>> {
    let watcher = ThemeWatcher::new()?;

    // Connect to color scheme changes
    let engine = style_engine.clone();
    watcher.color_scheme_changed().connect(move |&scheme| {
        let mut eng = engine.write().unwrap();
        match scheme {
            ColorScheme::Dark => {
                eng.set_theme(Theme::dark());
                println!("Switched to dark theme");
            }
            ColorScheme::Light | ColorScheme::Unknown => {
                eng.set_theme(Theme::light());
                println!("Switched to light theme");
            }
        }
        eng.invalidate_all();
    });

    // Connect to high contrast changes
    let engine = style_engine.clone();
    watcher.high_contrast_changed().connect(move |&enabled| {
        if enabled {
            let mut eng = engine.write().unwrap();
            eng.set_theme(Theme::high_contrast());
            eng.invalidate_all();
            println!("Switched to high contrast theme");
        }
    });

    watcher.start()?;
    Ok(watcher)
}

Checking System Settings

use horizon_lattice::platform::{SystemTheme, ColorScheme};

fn check_system_theme() {
    // Get current color scheme
    let scheme = SystemTheme::color_scheme();
    match scheme {
        ColorScheme::Light => println!("System is in light mode"),
        ColorScheme::Dark => println!("System is in dark mode"),
        ColorScheme::Unknown => println!("Could not detect system theme"),
    }

    // Check high contrast
    if SystemTheme::is_high_contrast() {
        println!("High contrast is enabled");
    }

    // Get system accent color (if available)
    if let Some(accent) = SystemTheme::accent_color() {
        println!("System accent color: {:?}", accent.color);
    }
}

Step 6: Styling Individual Widgets

Apply custom styles to specific widgets:

Inline Styles

use horizon_lattice::widget::widgets::{Label, PushButton};
use horizon_lattice_style::{Style, LengthValue};
use horizon_lattice::render::Color;

// Style a label
let mut label = Label::new("Styled Label");
label.set_style(
    Style::new()
        .color(Color::from_hex("#6200EE").unwrap())
        .font_size(LengthValue::Px(24.0))
        .font_weight(horizon_lattice_style::FontWeight::Bold)
        .build()
);

// Style a button
let mut button = PushButton::new("Custom Button");
button.set_style(
    Style::new()
        .background_color(Color::from_hex("#03DAC6").unwrap())
        .color(Color::BLACK)
        .padding_all(LengthValue::Px(16.0))
        .border_radius_all(8.0)
        .build()
);

Widget Classes

Use CSS-like classes for reusable styles:

use horizon_lattice::widget::widgets::{Label, PushButton, Container};
use horizon_lattice_style::{StyleSheet, StylePriority, Selector, Style};
use horizon_lattice::render::Color;

// Create a stylesheet with class rules
let mut stylesheet = StyleSheet::application();

// Add a "highlight" class
stylesheet.add_rule(
    Selector::class("highlight"),
    Style::new()
        .background_color(Color::from_rgba(255, 235, 59, 0.3)) // Yellow tint
        .border_width_all(LengthValue::Px(2.0))
        .border_color(Color::from_rgb8(255, 235, 59))
        .border_radius_all(4.0)
        .build()
);

// Add a "large-text" class
stylesheet.add_rule(
    Selector::class("large-text"),
    Style::new()
        .font_size(LengthValue::Px(20.0))
        .line_height(1.6)
        .build()
);

// Register stylesheet with engine
style_engine.add_stylesheet(stylesheet);

// Apply classes to widgets
let mut label = Label::new("Highlighted text");
label.add_class("highlight");
label.add_class("large-text");

State-based Styling

Style widgets differently based on state:

use horizon_lattice_style::{Selector, SelectorState};

// Style for hovered buttons
stylesheet.add_rule(
    Selector::widget("Button").with_state(SelectorState::Hovered),
    Style::new()
        .background_color(Color::from_hex("#7C4DFF").unwrap())
        .build()
);

// Style for pressed buttons
stylesheet.add_rule(
    Selector::widget("Button").with_state(SelectorState::Pressed),
    Style::new()
        .background_color(Color::from_hex("#5E35B1").unwrap())
        .build()
);

// Style for focused inputs
stylesheet.add_rule(
    Selector::widget("LineEdit").with_state(SelectorState::Focused),
    Style::new()
        .border_color(Color::from_hex("#6200EE").unwrap())
        .border_width_all(LengthValue::Px(2.0))
        .build()
);

// Style for disabled widgets
stylesheet.add_rule(
    Selector::any().with_state(SelectorState::Disabled),
    Style::new()
        .opacity(0.5)
        .build()
);

Complete Example: Theme-Aware App

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{
    Label, PushButton, CheckBox, LineEdit, Container, Window, ButtonVariant
};
use horizon_lattice::widget::layout::{VBoxLayout, HBoxLayout, ContentMargins, LayoutKind};
use horizon_lattice::platform::{SystemTheme, ColorScheme, ThemeWatcher};
use horizon_lattice_style::{StyleEngine, Theme, ColorPalette, ThemeMode, Style, LengthValue};
use horizon_lattice::render::Color;
use std::sync::{Arc, RwLock};

fn create_custom_light_theme() -> Theme {
    let mut palette = ColorPalette::light();
    palette.primary = Color::from_hex("#1976D2").unwrap();     // Blue
    palette.secondary = Color::from_hex("#FF5722").unwrap();   // Orange
    Theme::custom(ThemeMode::Light, palette)
}

fn create_custom_dark_theme() -> Theme {
    let mut palette = ColorPalette::dark();
    palette.primary = Color::from_hex("#90CAF9").unwrap();     // Light blue
    palette.secondary = Color::from_hex("#FFAB91").unwrap();   // Light orange
    palette.background = Color::from_rgb8(18, 18, 18);
    Theme::custom(ThemeMode::Dark, palette)
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    // Initialize with system theme preference
    let initial_theme = match SystemTheme::color_scheme() {
        ColorScheme::Dark => create_custom_dark_theme(),
        _ => create_custom_light_theme(),
    };

    let style_engine = Arc::new(RwLock::new(StyleEngine::new(initial_theme)));
    app.set_shared_style_engine(style_engine.clone());

    let mut window = Window::new("Theme-Aware App")
        .with_size(500.0, 400.0);

    // Title
    let mut title = Label::new("Settings");
    title.set_style(
        Style::new()
            .font_size(LengthValue::Px(24.0))
            .font_weight(horizon_lattice_style::FontWeight::Bold)
            .build()
    );

    // Theme selection
    let theme_label = Label::new("Theme:");

    let follow_system = CheckBox::new("Follow system theme");
    follow_system.set_checked(true);

    let light_btn = PushButton::new("Light");
    let dark_btn = PushButton::new("Dark")
        .with_variant(ButtonVariant::Secondary);

    // Name input
    let name_label = Label::new("Display name:");
    let name_input = LineEdit::new();
    name_input.set_placeholder("Enter your name...");

    // Save button
    let save_btn = PushButton::new("Save Settings")
        .with_default(true);

    // Track if following system
    let following_system = Arc::new(std::sync::atomic::AtomicBool::new(true));

    // Follow system checkbox
    let following = following_system.clone();
    let engine = style_engine.clone();
    follow_system.toggled().connect(move |&checked| {
        following.store(checked, std::sync::atomic::Ordering::SeqCst);
        if checked {
            // Switch to current system theme
            let mut eng = engine.write().unwrap();
            match SystemTheme::color_scheme() {
                ColorScheme::Dark => eng.set_theme(create_custom_dark_theme()),
                _ => eng.set_theme(create_custom_light_theme()),
            }
            eng.invalidate_all();
        }
    });

    // Light button
    let following = following_system.clone();
    let engine = style_engine.clone();
    let checkbox = follow_system.clone();
    light_btn.clicked().connect(move |_| {
        checkbox.set_checked(false);
        following.store(false, std::sync::atomic::Ordering::SeqCst);
        let mut eng = engine.write().unwrap();
        eng.set_theme(create_custom_light_theme());
        eng.invalidate_all();
    });

    // Dark button
    let following = following_system.clone();
    let engine = style_engine.clone();
    let checkbox = follow_system.clone();
    dark_btn.clicked().connect(move |_| {
        checkbox.set_checked(false);
        following.store(false, std::sync::atomic::Ordering::SeqCst);
        let mut eng = engine.write().unwrap();
        eng.set_theme(create_custom_dark_theme());
        eng.invalidate_all();
    });

    // Set up system theme watcher
    let watcher = ThemeWatcher::new()?;
    let engine = style_engine.clone();
    let following = following_system.clone();
    watcher.color_scheme_changed().connect(move |&scheme| {
        if following.load(std::sync::atomic::Ordering::SeqCst) {
            let mut eng = engine.write().unwrap();
            match scheme {
                ColorScheme::Dark => eng.set_theme(create_custom_dark_theme()),
                _ => eng.set_theme(create_custom_light_theme()),
            }
            eng.invalidate_all();
        }
    });
    watcher.start()?;

    // Save button action
    let input = name_input.clone();
    save_btn.clicked().connect(move |_| {
        let name = input.text();
        println!("Saved settings for: {}", name);
    });

    // Theme buttons row
    let mut theme_buttons = HBoxLayout::new();
    theme_buttons.set_spacing(8.0);
    theme_buttons.add_widget(light_btn.object_id());
    theme_buttons.add_widget(dark_btn.object_id());

    let mut theme_btn_container = Container::new();
    theme_btn_container.set_layout(LayoutKind::from(theme_buttons));

    // Main layout
    let mut layout = VBoxLayout::new();
    layout.set_content_margins(ContentMargins::uniform(24.0));
    layout.set_spacing(16.0);
    layout.add_widget(title.object_id());
    layout.add_widget(theme_label.object_id());
    layout.add_widget(follow_system.object_id());
    layout.add_widget(theme_btn_container.object_id());
    layout.add_stretch(1);
    layout.add_widget(name_label.object_id());
    layout.add_widget(name_input.object_id());
    layout.add_stretch(1);
    layout.add_widget(save_btn.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(layout));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

Theme Variables

Themes expose CSS-like variables for consistent styling:

use horizon_lattice_style::ThemeVariables;

// Variables automatically created from palette
// --primary-color, --primary-light, --primary-dark
// --secondary-color, --secondary-light, --secondary-dark
// --background, --surface, --surface-variant
// --text-primary, --text-secondary, --text-disabled
// --error, --warning, --success, --info
// --border, --border-light, --divider

// Spacing variables
// --spacing-xs (4px), --spacing-sm (8px), --spacing-md (16px)
// --spacing-lg (24px), --spacing-xl (32px)

// Border radius variables
// --radius-sm, --radius-md, --radius-lg, --radius-full

// Font size variables
// --font-size-xs through --font-size-2xl

Best Practices

1. Always Support Both Light and Dark

Design your custom themes in pairs:

fn get_theme(dark: bool) -> Theme {
    if dark {
        create_custom_dark_theme()
    } else {
        create_custom_light_theme()
    }
}

2. Use Semantic Colors

Use palette colors by meaning, not by value:

// Good - uses semantic meaning
let bg = palette.surface;
let text = palette.text_primary;
let accent = palette.primary;

// Bad - hardcoded colors that won't adapt
let bg = Color::WHITE;
let text = Color::BLACK;

3. Invalidate After Theme Changes

Always call invalidate_all() after changing themes:

engine.set_theme(new_theme);
engine.invalidate_all(); // Don't forget this!

4. Respect System Preferences

Default to following system theme, with manual override option:

// Good UX: follow system by default
let initial = match SystemTheme::color_scheme() {
    ColorScheme::Dark => Theme::dark(),
    _ => Theme::light(),
};

// Let users override manually if they want

5. Test High Contrast Mode

Always test your app with high contrast theme for accessibility:

// Ensure text is readable in high contrast
let hc_theme = Theme::high_contrast();
// Minimum 4.5:1 contrast ratio for text

Next Steps

Tutorial: File Operations

Learn to work with files, dialogs, and application settings.

What You’ll Learn

  • Using native file dialogs
  • Reading and writing files
  • Managing application settings
  • Working with application directories

Prerequisites

  • Completed the Theming tutorial
  • Understanding of Rust’s std::fs module

Step 1: Native File Dialogs

Horizon Lattice provides native file dialogs that integrate with the operating system:

use horizon_lattice::widget::widgets::native_dialogs::{
    NativeFileDialogOptions, NativeFileFilter,
    open_file, open_files, save_file, select_directory
};

fn main() {
    // Open a single file
    let options = NativeFileDialogOptions::with_title("Open Document")
        .filter(NativeFileFilter::new("Text Files", &["txt", "md"]))
        .filter(NativeFileFilter::new("All Files", &["*"]));

    if let Some(path) = open_file(options) {
        println!("Selected: {:?}", path);
    }
}

Open Multiple Files

let options = NativeFileDialogOptions::with_title("Select Images")
    .filter(NativeFileFilter::new("Images", &["png", "jpg", "jpeg", "gif"]))
    .multiple(true);

if let Some(paths) = open_files(options) {
    for path in paths {
        println!("Selected: {:?}", path);
    }
}

Save File Dialog

let options = NativeFileDialogOptions::with_title("Save Document")
    .default_name("untitled.txt")
    .filter(NativeFileFilter::new("Text Files", &["txt"]));

if let Some(path) = save_file(options) {
    println!("Save to: {:?}", path);
}

Select Directory

let options = NativeFileDialogOptions::with_title("Choose Folder")
    .directory("/home/user/Documents");

if let Some(path) = select_directory(options) {
    println!("Selected directory: {:?}", path);
}

Step 2: The FileDialog Widget

For more control, use the FileDialog widget:

use horizon_lattice::widget::widgets::{FileDialog, FileDialogMode, FileFilter};
use std::path::PathBuf;

// Create dialog for opening files
let dialog = FileDialog::for_open()
    .with_title("Open Project")
    .with_directory("/home/user/projects")
    .with_filter(FileFilter::new("Rust Files", &["*.rs", "*.toml"]));

// Connect to selection signal
dialog.file_selected.connect(|path: &PathBuf| {
    println!("Selected: {:?}", path);
});

// Show the dialog
dialog.open();

Static Helper Methods

use horizon_lattice::widget::widgets::{FileDialog, FileFilter};

// Quick open file dialog
let filters = vec![
    FileFilter::text_files(),
    FileFilter::all_files(),
];

if let Some(path) = FileDialog::get_open_file_name("Open", "/home", &filters) {
    println!("Opening: {:?}", path);
}

// Quick save file dialog
if let Some(path) = FileDialog::get_save_file_name("Save As", "/home", &filters) {
    println!("Saving to: {:?}", path);
}

// Quick directory selection
if let Some(path) = FileDialog::get_existing_directory("Select Folder", "/home") {
    println!("Directory: {:?}", path);
}

Step 3: Reading and Writing Files

Horizon Lattice provides convenient file operations:

Quick Operations

use horizon_lattice::file::operations::{
    read_text, read_bytes, read_lines,
    write_text, write_bytes, append_text
};

// Read entire file as string
let content = read_text("config.txt")?;

// Read file as bytes
let data = read_bytes("image.png")?;

// Read file line by line
let lines = read_lines("data.csv")?;
for line in lines {
    println!("{}", line);
}

// Write string to file
write_text("output.txt", "Hello, World!")?;

// Write bytes to file
write_bytes("data.bin", &[0x00, 0x01, 0x02])?;

// Append to file
append_text("log.txt", "New log entry\n")?;

File Reader

use horizon_lattice::file::File;

let mut file = File::open("document.txt")?;

// Read entire content
let content = file.read_to_string()?;

// Or iterate over lines
let file = File::open("document.txt")?;
for line in file.lines() {
    let line = line?;
    println!("{}", line);
}

File Writer

use horizon_lattice::file::FileWriter;

// Create new file (overwrites existing)
let mut writer = FileWriter::create("output.txt")?;
writer.write_str("Line 1\n")?;
writer.write_line("Line 2")?;
writer.flush()?;

// Append to existing file
let mut writer = FileWriter::append("log.txt")?;
writer.write_line("New entry")?;

Atomic Writes (Safe for Config Files)

use horizon_lattice::file::operations::atomic_write;
use horizon_lattice::file::AtomicWriter;

// Atomic write ensures file is complete or unchanged
atomic_write("config.json", |writer: &mut AtomicWriter| {
    writer.write_str("{\n")?;
    writer.write_str("  \"version\": 1\n")?;
    writer.write_str("}\n")?;
    Ok(())
})?;
// File is atomically renamed only if write succeeds

Step 4: Application Settings

The Settings API provides a hierarchical key-value store:

use horizon_lattice::file::Settings;

// Create settings
let settings = Settings::new();

// Store values (hierarchical keys with . or / separator)
settings.set("app.window.width", 1024);
settings.set("app.window.height", 768);
settings.set("app/theme/name", "dark");
settings.set("app.recent_files", vec!["file1.txt", "file2.txt"]);

// Retrieve values with type safety
let width: i32 = settings.get("app.window.width").unwrap_or(800);
let theme: String = settings.get_or("app.theme.name", "light".to_string());

// Check if key exists
if settings.contains("app.window.width") {
    println!("Width is configured");
}

// List keys in a group
let window_keys = settings.group_keys("app.window");
// Returns: ["width", "height"]

Persisting Settings

use horizon_lattice::file::{Settings, SettingsFormat};

// Save to JSON
settings.save_json("config.json")?;

// Save to TOML
settings.save_toml("config.toml")?;

// Save to INI (flat structure)
settings.save_ini("config.ini")?;

// Load from file
let settings = Settings::load_json("config.json")?;
let settings = Settings::load_toml("config.toml")?;

Auto-Save Settings

use horizon_lattice::file::{Settings, SettingsFormat};

let settings = Settings::new();

// Enable auto-save (writes on every change)
settings.set_auto_save("config.json", SettingsFormat::Json);

// Changes are automatically persisted
settings.set("app.volume", 75);  // Saved automatically

// Force immediate write
settings.sync()?;

// Disable auto-save
settings.disable_auto_save();

Listening to Changes

let settings = Settings::new();

// Connect to change signal
settings.changed().connect(|key: &String| {
    println!("Setting changed: {}", key);
});

settings.set("app.theme", "dark");
// Prints: "Setting changed: app.theme"

Step 5: Application Directories

Get standard directories for your application:

use horizon_lattice::file::path::{
    home_dir, config_dir, data_dir, cache_dir,
    documents_dir, downloads_dir, AppPaths
};

// Standard user directories
let home = home_dir()?;           // /home/user
let config = config_dir()?;       // /home/user/.config
let data = data_dir()?;           // /home/user/.local/share
let cache = cache_dir()?;         // /home/user/.cache
let docs = documents_dir()?;      // /home/user/Documents
let downloads = downloads_dir()?; // /home/user/Downloads

// Application-specific directories
let app_paths = AppPaths::new("com", "example", "myapp")?;
let app_config = app_paths.config();     // ~/.config/myapp
let app_data = app_paths.data();         // ~/.local/share/myapp
let app_cache = app_paths.cache();       // ~/.cache/myapp
let app_logs = app_paths.logs();         // ~/.local/share/myapp/logs

Step 6: File Information

Query file metadata:

use horizon_lattice::file::{FileInfo, exists, is_file, is_dir, file_size};

// Quick checks
if exists("config.json") {
    println!("Config exists");
}

if is_file("document.txt") {
    let size = file_size("document.txt")?;
    println!("Size: {} bytes", size);
}

if is_dir("projects") {
    println!("Projects directory exists");
}

// Detailed file information
let info = FileInfo::new("document.txt")?;
println!("Size: {} bytes", info.size());
println!("Is readable: {}", info.is_readable());
println!("Is writable: {}", info.is_writable());

if let Some(modified) = info.modified() {
    println!("Modified: {:?}", modified);
}

Complete Example: Note Taking App

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{
    Window, Container, TextEdit, PushButton, Label,
    FileDialog, FileFilter, ButtonVariant
};
use horizon_lattice::widget::layout::{VBoxLayout, HBoxLayout, ContentMargins, LayoutKind};
use horizon_lattice::file::{Settings, operations::{read_text, atomic_write}, path::AppPaths};
use std::sync::{Arc, Mutex};
use std::path::PathBuf;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    // Setup app directories and settings
    let app_paths = AppPaths::new("com", "example", "notes")?;
    let settings_path = app_paths.config().join("settings.json");

    let settings = if settings_path.exists() {
        Settings::load_json(&settings_path)?
    } else {
        Settings::new()
    };
    settings.set_auto_save(&settings_path, horizon_lattice::file::SettingsFormat::Json);

    // Track current file
    let current_file: Arc<Mutex<Option<PathBuf>>> = Arc::new(Mutex::new(None));

    // Window setup
    let mut window = Window::new("Notes")
        .with_size(
            settings.get_or("window.width", 600),
            settings.get_or("window.height", 400),
        );

    // Title showing current file
    let title_label = Label::new("Untitled");

    // Text editor
    let text_edit = TextEdit::new();
    text_edit.set_placeholder("Start typing...");

    // Buttons
    let new_btn = PushButton::new("New");
    let open_btn = PushButton::new("Open");
    let save_btn = PushButton::new("Save")
        .with_variant(ButtonVariant::Primary);
    let save_as_btn = PushButton::new("Save As");

    // New button - clear editor
    let editor = text_edit.clone();
    let title = title_label.clone();
    let file = current_file.clone();
    new_btn.clicked().connect(move |_| {
        editor.set_text("");
        title.set_text("Untitled");
        *file.lock().unwrap() = None;
    });

    // Open button
    let editor = text_edit.clone();
    let title = title_label.clone();
    let file = current_file.clone();
    open_btn.clicked().connect(move |_| {
        let filters = vec![
            FileFilter::text_files(),
            FileFilter::all_files(),
        ];

        if let Some(path) = FileDialog::get_open_file_name("Open Note", "", &filters) {
            match read_text(&path) {
                Ok(content) => {
                    editor.set_text(&content);
                    title.set_text(path.file_name().unwrap().to_str().unwrap());
                    *file.lock().unwrap() = Some(path);
                }
                Err(e) => {
                    eprintln!("Failed to open file: {}", e);
                }
            }
        }
    });

    // Save button
    let editor = text_edit.clone();
    let file = current_file.clone();
    save_btn.clicked().connect(move |_| {
        let file_lock = file.lock().unwrap();
        if let Some(ref path) = *file_lock {
            let content = editor.text();
            if let Err(e) = atomic_write(path, |w| {
                w.write_str(&content)
            }) {
                eprintln!("Failed to save: {}", e);
            }
        } else {
            // No file set, trigger Save As
            drop(file_lock);
            // Would trigger save_as here
        }
    });

    // Save As button
    let editor = text_edit.clone();
    let title = title_label.clone();
    let file = current_file.clone();
    save_as_btn.clicked().connect(move |_| {
        let filters = vec![
            FileFilter::text_files(),
            FileFilter::all_files(),
        ];

        if let Some(path) = FileDialog::get_save_file_name("Save Note", "", &filters) {
            let content = editor.text();
            match atomic_write(&path, |w| w.write_str(&content)) {
                Ok(()) => {
                    title.set_text(path.file_name().unwrap().to_str().unwrap());
                    *file.lock().unwrap() = Some(path);
                }
                Err(e) => {
                    eprintln!("Failed to save: {}", e);
                }
            }
        }
    });

    // Button row
    let mut button_row = HBoxLayout::new();
    button_row.set_spacing(8.0);
    button_row.add_widget(new_btn.object_id());
    button_row.add_widget(open_btn.object_id());
    button_row.add_widget(save_btn.object_id());
    button_row.add_widget(save_as_btn.object_id());
    button_row.add_stretch(1);

    let mut button_container = Container::new();
    button_container.set_layout(LayoutKind::from(button_row));

    // Main layout
    let mut layout = VBoxLayout::new();
    layout.set_content_margins(ContentMargins::uniform(12.0));
    layout.set_spacing(8.0);
    layout.add_widget(title_label.object_id());
    layout.add_widget(button_container.object_id());
    layout.add_widget(text_edit.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(layout));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

Error Handling

File operations use FileResult<T> which is Result<T, FileError>:

use horizon_lattice::file::{FileResult, FileError, operations::read_text};

fn load_config() -> FileResult<String> {
    read_text("config.json")
}

fn main() {
    match load_config() {
        Ok(content) => println!("Loaded: {}", content),
        Err(e) if e.is_not_found() => {
            println!("Config not found, using defaults");
        }
        Err(e) if e.is_permission_denied() => {
            eprintln!("Permission denied: {}", e);
        }
        Err(e) => {
            eprintln!("Error: {}", e);
        }
    }
}

Best Practices

1. Use Atomic Writes for Config Files

// Bad - can leave corrupted file on crash
write_text("config.json", &content)?;

// Good - atomic operation
atomic_write("config.json", |w| w.write_str(&content))?;

2. Use AppPaths for Application Files

// Bad - hardcoded paths
let config_path = "/home/user/.myapp/config.json";

// Good - platform-appropriate paths
let app = AppPaths::new("com", "company", "myapp")?;
let config_path = app.config().join("config.json");

3. Validate File Paths Before Use

use horizon_lattice::file::{exists, is_file, is_readable};

if exists(&path) && is_file(&path) && is_readable(&path) {
    let content = read_text(&path)?;
}

4. Save Window State in Settings

// On window resize
settings.set("window.width", window.width());
settings.set("window.height", window.height());

// On startup
let width = settings.get_or("window.width", 800);
let height = settings.get_or("window.height", 600);

Next Steps

Example: Calculator

A functional calculator demonstrating button grids, state management, and signal handling.

Overview

This example builds a basic calculator with:

  • 4x5 button grid for digits and operations
  • Display label showing current value
  • Keyboard input support
  • Basic arithmetic operations (+, -, *, /)

Key Concepts

  • GridLayout: Arranging buttons in a 2D grid
  • Signal connections: Handling button clicks
  • State management: Tracking calculator state with Arc/Mutex
  • Keyboard events: Accepting keyboard input

Implementation

Calculator State

use std::sync::{Arc, Mutex};

#[derive(Clone)]
struct CalculatorState {
    display: String,
    operand: Option<f64>,
    operator: Option<char>,
    clear_on_next: bool,
}

impl CalculatorState {
    fn new() -> Self {
        Self {
            display: "0".to_string(),
            operand: None,
            operator: None,
            clear_on_next: false,
        }
    }

    fn input_digit(&mut self, digit: char) {
        if self.clear_on_next {
            self.display = String::new();
            self.clear_on_next = false;
        }
        if self.display == "0" && digit != '.' {
            self.display = digit.to_string();
        } else if digit == '.' && self.display.contains('.') {
            // Ignore duplicate decimal
        } else {
            self.display.push(digit);
        }
    }

    fn input_operator(&mut self, op: char) {
        let current = self.display.parse::<f64>().unwrap_or(0.0);

        if let (Some(operand), Some(prev_op)) = (self.operand, self.operator) {
            let result = Self::calculate(operand, current, prev_op);
            self.display = Self::format_result(result);
            self.operand = Some(result);
        } else {
            self.operand = Some(current);
        }

        self.operator = Some(op);
        self.clear_on_next = true;
    }

    fn calculate(a: f64, b: f64, op: char) -> f64 {
        match op {
            '+' => a + b,
            '-' => a - b,
            '*' => a * b,
            '/' => if b != 0.0 { a / b } else { f64::NAN },
            _ => b,
        }
    }

    fn equals(&mut self) {
        if let (Some(operand), Some(op)) = (self.operand, self.operator) {
            let current = self.display.parse::<f64>().unwrap_or(0.0);
            let result = Self::calculate(operand, current, op);
            self.display = Self::format_result(result);
            self.operand = None;
            self.operator = None;
            self.clear_on_next = true;
        }
    }

    fn clear(&mut self) {
        self.display = "0".to_string();
        self.operand = None;
        self.operator = None;
        self.clear_on_next = false;
    }

    fn format_result(value: f64) -> String {
        if value.is_nan() {
            "Error".to_string()
        } else if value.fract() == 0.0 && value.abs() < 1e10 {
            format!("{:.0}", value)
        } else {
            format!("{:.8}", value).trim_end_matches('0').trim_end_matches('.').to_string()
        }
    }
}

Button Factory

use horizon_lattice::widget::widgets::{PushButton, ButtonVariant};
use horizon_lattice::render::Color;

fn create_digit_button(digit: &str) -> PushButton {
    PushButton::new(digit)
        .with_variant(ButtonVariant::Secondary)
}

fn create_operator_button(op: &str) -> PushButton {
    PushButton::new(op)
        .with_variant(ButtonVariant::Primary)
}

fn create_special_button(text: &str) -> PushButton {
    PushButton::new(text)
        .with_variant(ButtonVariant::Outlined)
}

Full Source

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{
    Window, Container, Label, PushButton, ButtonVariant
};
use horizon_lattice::widget::layout::{GridLayout, VBoxLayout, ContentMargins, LayoutKind};
use horizon_lattice::widget::{Widget, WidgetEvent, Key};
use horizon_lattice::render::Color;
use horizon_lattice_style::{Style, LengthValue};
use std::sync::{Arc, Mutex};

// Calculator state (from above)
#[derive(Clone)]
struct CalculatorState {
    display: String,
    operand: Option<f64>,
    operator: Option<char>,
    clear_on_next: bool,
}

impl CalculatorState {
    fn new() -> Self {
        Self {
            display: "0".to_string(),
            operand: None,
            operator: None,
            clear_on_next: false,
        }
    }

    fn input_digit(&mut self, digit: char) {
        if self.clear_on_next {
            self.display = String::new();
            self.clear_on_next = false;
        }
        if self.display == "0" && digit != '.' {
            self.display = digit.to_string();
        } else if digit == '.' && self.display.contains('.') {
            // Ignore duplicate decimal
        } else {
            self.display.push(digit);
        }
    }

    fn input_operator(&mut self, op: char) {
        let current = self.display.parse::<f64>().unwrap_or(0.0);
        if let (Some(operand), Some(prev_op)) = (self.operand, self.operator) {
            let result = Self::calculate(operand, current, prev_op);
            self.display = Self::format_result(result);
            self.operand = Some(result);
        } else {
            self.operand = Some(current);
        }
        self.operator = Some(op);
        self.clear_on_next = true;
    }

    fn calculate(a: f64, b: f64, op: char) -> f64 {
        match op {
            '+' => a + b,
            '-' => a - b,
            '*' => a * b,
            '/' => if b != 0.0 { a / b } else { f64::NAN },
            _ => b,
        }
    }

    fn equals(&mut self) {
        if let (Some(operand), Some(op)) = (self.operand, self.operator) {
            let current = self.display.parse::<f64>().unwrap_or(0.0);
            let result = Self::calculate(operand, current, op);
            self.display = Self::format_result(result);
            self.operand = None;
            self.operator = None;
            self.clear_on_next = true;
        }
    }

    fn clear(&mut self) {
        self.display = "0".to_string();
        self.operand = None;
        self.operator = None;
        self.clear_on_next = false;
    }

    fn format_result(value: f64) -> String {
        if value.is_nan() {
            "Error".to_string()
        } else if value.fract() == 0.0 && value.abs() < 1e10 {
            format!("{:.0}", value)
        } else {
            format!("{:.8}", value)
                .trim_end_matches('0')
                .trim_end_matches('.')
                .to_string()
        }
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("Calculator")
        .with_size(280.0, 400.0);

    // Shared state
    let state = Arc::new(Mutex::new(CalculatorState::new()));

    // Display label
    let mut display = Label::new("0");
    display.set_style(
        Style::new()
            .font_size(LengthValue::Px(32.0))
            .padding_all(LengthValue::Px(16.0))
            .background_color(Color::from_rgb8(40, 40, 40))
            .color(Color::WHITE)
            .build()
    );

    // Create buttons
    let buttons = [
        ("C", 0, 0, ButtonVariant::Outlined),
        ("+/-", 0, 1, ButtonVariant::Outlined),
        ("%", 0, 2, ButtonVariant::Outlined),
        ("/", 0, 3, ButtonVariant::Primary),
        ("7", 1, 0, ButtonVariant::Secondary),
        ("8", 1, 1, ButtonVariant::Secondary),
        ("9", 1, 2, ButtonVariant::Secondary),
        ("*", 1, 3, ButtonVariant::Primary),
        ("4", 2, 0, ButtonVariant::Secondary),
        ("5", 2, 1, ButtonVariant::Secondary),
        ("6", 2, 2, ButtonVariant::Secondary),
        ("-", 2, 3, ButtonVariant::Primary),
        ("1", 3, 0, ButtonVariant::Secondary),
        ("2", 3, 1, ButtonVariant::Secondary),
        ("3", 3, 2, ButtonVariant::Secondary),
        ("+", 3, 3, ButtonVariant::Primary),
        ("0", 4, 0, ButtonVariant::Secondary), // Will span 2 columns
        (".", 4, 2, ButtonVariant::Secondary),
        ("=", 4, 3, ButtonVariant::Primary),
    ];

    // Build grid layout
    let mut grid = GridLayout::new();
    grid.set_horizontal_spacing(4.0);
    grid.set_vertical_spacing(4.0);

    for (text, row, col, variant) in buttons {
        let button = PushButton::new(text).with_variant(variant);

        // Connect button to calculator logic
        let state_clone = state.clone();
        let display_clone = display.clone();
        let text_owned = text.to_string();

        button.clicked().connect(move |_| {
            let mut calc = state_clone.lock().unwrap();
            let ch = text_owned.chars().next().unwrap();

            match text_owned.as_str() {
                "C" => calc.clear(),
                "=" => calc.equals(),
                "+/-" => {
                    if let Ok(val) = calc.display.parse::<f64>() {
                        calc.display = CalculatorState::format_result(-val);
                    }
                }
                "%" => {
                    if let Ok(val) = calc.display.parse::<f64>() {
                        calc.display = CalculatorState::format_result(val / 100.0);
                    }
                }
                "+" | "-" | "*" | "/" => calc.input_operator(ch),
                _ => calc.input_digit(ch),
            }

            display_clone.set_text(&calc.display);
        });

        // Special case: "0" spans 2 columns
        if text == "0" {
            grid.add_widget_spanning(button.object_id(), row, col, 1, 2);
        } else {
            grid.add_widget_at(button.object_id(), row, col);
        }
    }

    let mut grid_container = Container::new();
    grid_container.set_layout(LayoutKind::from(grid));

    // Main layout
    let mut layout = VBoxLayout::new();
    layout.set_content_margins(ContentMargins::uniform(8.0));
    layout.set_spacing(8.0);
    layout.add_widget(display.object_id());
    layout.add_widget(grid_container.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(layout));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

Features Demonstrated

FeatureDescription
GridLayout4-column button grid with cell spanning
State ManagementArc/Mutex for shared calculator state
Signal ConnectionsButton clicks update display
Button VariantsVisual distinction between button types
Inline StylingCustom display label styling
Builder PatternFluent widget configuration

Exercises

  1. Add keyboard support: Handle number keys, operators, Enter for equals, Escape for clear
  2. Add memory functions: M+, M-, MR, MC buttons
  3. Add scientific functions: sin, cos, tan, sqrt, power
  4. Add history: Show previous calculations
  5. Add parentheses: Support expression grouping

Example: Text Editor

A functional text editor demonstrating file operations, menus, and text editing.

Overview

This example builds a text editor with:

  • Multi-line text editing with TextEdit widget
  • File menu with New, Open, Save, Save As
  • Edit menu with Undo, Redo, Cut, Copy, Paste
  • Status bar showing cursor position
  • Dirty file tracking with save prompts

Key Concepts

  • MainWindow: Application window with menu bar and status bar
  • MenuBar and Menu: Standard application menus with keyboard shortcuts
  • TextEdit: Multi-line text editing widget
  • File dialogs: Open and save file dialogs
  • Action: Reusable menu/toolbar commands

Full Source

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{
    MainWindow, TextEdit, StatusBar, Menu, MenuBar, Action,
    FileDialog, FileFilter, MessageBox
};
use horizon_lattice::file::operations::{read_text, atomic_write};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};

struct EditorState {
    current_file: Option<PathBuf>,
    is_modified: bool,
}

impl EditorState {
    fn new() -> Self {
        Self { current_file: None, is_modified: false }
    }

    fn window_title(&self) -> String {
        let name = self.current_file
            .as_ref()
            .and_then(|p| p.file_name())
            .and_then(|n| n.to_str())
            .unwrap_or("Untitled");
        if self.is_modified {
            format!("*{} - Text Editor", name)
        } else {
            format!("{} - Text Editor", name)
        }
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;
    let state = Arc::new(Mutex::new(EditorState::new()));

    let mut window = MainWindow::new("Text Editor")
        .with_size(800.0, 600.0);

    // Text editor widget
    let text_edit = TextEdit::new();

    // Status bar
    let status_bar = StatusBar::new();
    status_bar.show_message("Ready");

    // Track modifications
    let state_clone = state.clone();
    let window_clone = window.clone();
    text_edit.text_changed.connect(move || {
        let mut s = state_clone.lock().unwrap();
        s.is_modified = true;
        window_clone.set_title(&s.window_title());
    });

    // === File Menu ===
    let mut file_menu = Menu::new("File");

    let new_action = Action::new("New").with_shortcut("Ctrl+N");
    let open_action = Action::new("Open...").with_shortcut("Ctrl+O");
    let save_action = Action::new("Save").with_shortcut("Ctrl+S");
    let save_as_action = Action::new("Save As...");
    let quit_action = Action::new("Quit").with_shortcut("Ctrl+Q");

    // New file handler
    let editor = text_edit.clone();
    let state_clone = state.clone();
    let window_clone = window.clone();
    new_action.triggered.connect(move || {
        editor.set_text("");
        let mut s = state_clone.lock().unwrap();
        s.current_file = None;
        s.is_modified = false;
        window_clone.set_title(&s.window_title());
    });

    // Open file handler
    let editor = text_edit.clone();
    let state_clone = state.clone();
    let window_clone = window.clone();
    open_action.triggered.connect(move || {
        let filters = vec![FileFilter::text_files(), FileFilter::all_files()];
        if let Some(path) = FileDialog::get_open_file_name("Open", "", &filters) {
            if let Ok(content) = read_text(&path) {
                editor.set_text(&content);
                let mut s = state_clone.lock().unwrap();
                s.current_file = Some(path);
                s.is_modified = false;
                window_clone.set_title(&s.window_title());
            }
        }
    });

    // Save file handler
    let editor = text_edit.clone();
    let state_clone = state.clone();
    let window_clone = window.clone();
    save_action.triggered.connect(move || {
        let s = state_clone.lock().unwrap();
        if let Some(ref path) = s.current_file {
            let content = editor.text();
            drop(s);
            if atomic_write(path, |w| w.write_str(&content)).is_ok() {
                let mut s = state_clone.lock().unwrap();
                s.is_modified = false;
                window_clone.set_title(&s.window_title());
            }
        }
    });

    // Save As handler
    let editor = text_edit.clone();
    let state_clone = state.clone();
    let window_clone = window.clone();
    save_as_action.triggered.connect(move || {
        let filters = vec![FileFilter::text_files(), FileFilter::all_files()];
        if let Some(path) = FileDialog::get_save_file_name("Save As", "", &filters) {
            let content = editor.text();
            if atomic_write(&path, |w| w.write_str(&content)).is_ok() {
                let mut s = state_clone.lock().unwrap();
                s.current_file = Some(path);
                s.is_modified = false;
                window_clone.set_title(&s.window_title());
            }
        }
    });

    // Quit handler
    let app_clone = app.clone();
    quit_action.triggered.connect(move || {
        app_clone.quit();
    });

    file_menu.add_action(new_action);
    file_menu.add_action(open_action);
    file_menu.add_separator();
    file_menu.add_action(save_action);
    file_menu.add_action(save_as_action);
    file_menu.add_separator();
    file_menu.add_action(quit_action);

    // === Edit Menu ===
    let mut edit_menu = Menu::new("Edit");

    let undo_action = Action::new("Undo").with_shortcut("Ctrl+Z");
    let redo_action = Action::new("Redo").with_shortcut("Ctrl+Y");
    let cut_action = Action::new("Cut").with_shortcut("Ctrl+X");
    let copy_action = Action::new("Copy").with_shortcut("Ctrl+C");
    let paste_action = Action::new("Paste").with_shortcut("Ctrl+V");
    let select_all_action = Action::new("Select All").with_shortcut("Ctrl+A");

    let editor = text_edit.clone();
    undo_action.triggered.connect(move || editor.undo());

    let editor = text_edit.clone();
    redo_action.triggered.connect(move || editor.redo());

    let editor = text_edit.clone();
    cut_action.triggered.connect(move || editor.cut());

    let editor = text_edit.clone();
    copy_action.triggered.connect(move || editor.copy());

    let editor = text_edit.clone();
    paste_action.triggered.connect(move || editor.paste());

    let editor = text_edit.clone();
    select_all_action.triggered.connect(move || editor.select_all());

    edit_menu.add_action(undo_action);
    edit_menu.add_action(redo_action);
    edit_menu.add_separator();
    edit_menu.add_action(cut_action);
    edit_menu.add_action(copy_action);
    edit_menu.add_action(paste_action);
    edit_menu.add_separator();
    edit_menu.add_action(select_all_action);

    // === View Menu ===
    let mut view_menu = Menu::new("View");
    let word_wrap_action = Action::new("Word Wrap").with_checkable(true);
    word_wrap_action.set_checked(true);

    let editor = text_edit.clone();
    word_wrap_action.toggled.connect(move |&checked| {
        editor.set_word_wrap(checked);
    });

    view_menu.add_action(word_wrap_action);

    // Build menu bar
    let mut menu_bar = MenuBar::new();
    menu_bar.add_menu(file_menu);
    menu_bar.add_menu(edit_menu);
    menu_bar.add_menu(view_menu);

    // Assemble window
    window.set_menu_bar(menu_bar);
    window.set_central_widget(text_edit.object_id());
    window.set_status_bar(status_bar);
    window.show();

    app.run()
}

Features Demonstrated

FeatureDescription
MainWindowWindow with menu bar, central widget, status bar
MenuBar/MenuHierarchical menu structure with separators
ActionCommands with keyboard shortcuts
TextEditMulti-line text editing with undo/redo
FileDialogNative file dialogs
State TrackingModified flag and dynamic window title

Exercises

  1. Add Find/Replace: Implement search with Ctrl+F
  2. Add recent files: Show recently opened files in menu
  3. Add line numbers: Display line numbers in margin
  4. Add syntax highlighting: Use PlainTextEdit with highlighter
  5. Add multiple tabs: Support multiple documents

Example: File Browser

A file browser demonstrating TreeView, ListView, and file operations.

Overview

This example builds a file browser with:

  • TreeView for directory hierarchy
  • ListView for file listing
  • Splitter for resizable panes
  • Toolbar with navigation buttons
  • Address bar for direct path entry

Key Concepts

  • TreeView: Hierarchical directory display
  • ListView: File listing with icons
  • Splitter: Resizable split view
  • Models: Custom data models for files
  • File operations: Reading directory contents

Full Source

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{
    Window, Container, TreeView, ListView, Splitter, ToolBar, LineEdit,
    PushButton, Label, Action
};
use horizon_lattice::widget::layout::{VBoxLayout, HBoxLayout, LayoutKind};
use horizon_lattice::model::{TreeModel, ListModel, ModelIndex};
use horizon_lattice::file::{FileInfo, path::{home_dir, parent}};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::fs;

#[derive(Clone)]
struct FileEntry {
    name: String,
    path: PathBuf,
    is_dir: bool,
    size: u64,
}

impl FileEntry {
    fn from_path(path: PathBuf) -> Option<Self> {
        let info = FileInfo::new(&path).ok()?;
        Some(Self {
            name: path.file_name()?.to_str()?.to_string(),
            path,
            is_dir: info.is_dir(),
            size: info.size(),
        })
    }

    fn size_string(&self) -> String {
        if self.is_dir {
            String::new()
        } else if self.size < 1024 {
            format!("{} B", self.size)
        } else if self.size < 1024 * 1024 {
            format!("{:.1} KB", self.size as f64 / 1024.0)
        } else {
            format!("{:.1} MB", self.size as f64 / (1024.0 * 1024.0))
        }
    }
}

fn list_directory(path: &PathBuf) -> Vec<FileEntry> {
    let mut entries = Vec::new();
    if let Ok(read_dir) = fs::read_dir(path) {
        for entry in read_dir.filter_map(|e| e.ok()) {
            if let Some(file_entry) = FileEntry::from_path(entry.path()) {
                entries.push(file_entry);
            }
        }
    }
    // Sort: directories first, then alphabetically
    entries.sort_by(|a, b| {
        match (a.is_dir, b.is_dir) {
            (true, false) => std::cmp::Ordering::Less,
            (false, true) => std::cmp::Ordering::Greater,
            _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
        }
    });
    entries
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("File Browser")
        .with_size(900.0, 600.0);

    // Current path state
    let current_path = Arc::new(Mutex::new(home_dir()?));

    // Address bar
    let address_bar = LineEdit::new();
    address_bar.set_text(current_path.lock().unwrap().to_str().unwrap());

    // Navigation buttons
    let back_btn = PushButton::new("Back");
    let up_btn = PushButton::new("Up");
    let home_btn = PushButton::new("Home");
    let refresh_btn = PushButton::new("Refresh");

    // Directory tree (left pane)
    let tree_view = TreeView::new();

    // File list (right pane)
    let list_model = Arc::new(Mutex::new(ListModel::new(Vec::<FileEntry>::new())));
    let list_view = ListView::new()
        .with_model(list_model.lock().unwrap().clone());

    // Function to update file list
    let update_list = {
        let model = list_model.clone();
        let address = address_bar.clone();
        move |path: &PathBuf| {
            let entries = list_directory(path);
            model.lock().unwrap().set_items(entries);
            address.set_text(path.to_str().unwrap_or(""));
        }
    };

    // Initial load
    update_list(&current_path.lock().unwrap().clone());

    // Back button (history would be implemented)
    back_btn.clicked().connect(|_| {
        // Would implement navigation history
    });

    // Up button
    let path = current_path.clone();
    let update = update_list.clone();
    up_btn.clicked().connect(move |_| {
        let mut p = path.lock().unwrap();
        if let Some(parent_path) = parent(&*p) {
            *p = parent_path.clone();
            update(&parent_path);
        }
    });

    // Home button
    let path = current_path.clone();
    let update = update_list.clone();
    home_btn.clicked().connect(move |_| {
        if let Ok(home) = home_dir() {
            *path.lock().unwrap() = home.clone();
            update(&home);
        }
    });

    // Refresh button
    let path = current_path.clone();
    let update = update_list.clone();
    refresh_btn.clicked().connect(move |_| {
        let p = path.lock().unwrap().clone();
        update(&p);
    });

    // Address bar enter key
    let path = current_path.clone();
    let update = update_list.clone();
    address_bar.return_pressed.connect(move || {
        let text = address_bar.text();
        let new_path = PathBuf::from(&text);
        if new_path.is_dir() {
            *path.lock().unwrap() = new_path.clone();
            update(&new_path);
        }
    });

    // Double-click on list item
    let path = current_path.clone();
    let model = list_model.clone();
    let update = update_list.clone();
    list_view.double_clicked.connect(move |index: &ModelIndex| {
        let m = model.lock().unwrap();
        if let Some(entry) = m.get(index.row() as usize) {
            if entry.is_dir {
                let new_path = entry.path.clone();
                drop(m);
                *path.lock().unwrap() = new_path.clone();
                update(&new_path);
            }
        }
    });

    // Toolbar layout
    let mut toolbar = HBoxLayout::new();
    toolbar.set_spacing(4.0);
    toolbar.add_widget(back_btn.object_id());
    toolbar.add_widget(up_btn.object_id());
    toolbar.add_widget(home_btn.object_id());
    toolbar.add_widget(refresh_btn.object_id());
    toolbar.add_widget(address_bar.object_id());

    let mut toolbar_container = Container::new();
    toolbar_container.set_layout(LayoutKind::from(toolbar));

    // Splitter with tree and list
    let mut splitter = Splitter::new();
    splitter.add_widget(tree_view.object_id());
    splitter.add_widget(list_view.object_id());
    splitter.set_sizes(&[200, 600]);

    // Main layout
    let mut layout = VBoxLayout::new();
    layout.add_widget(toolbar_container.object_id());
    layout.add_widget(splitter.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(layout));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

Features Demonstrated

FeatureDescription
TreeViewHierarchical directory tree
ListViewFile list with model
SplitterResizable split panes
ListModelDynamic file list model
File operationsReading directories
NavigationUp, Home, address bar

Exercises

  1. Add file icons: Show different icons for file types
  2. Add context menu: Right-click options (Open, Delete, Rename)
  3. Add file details: Show columns for size, date, type
  4. Add search: Filter files by name
  5. Add bookmarks: Quick access sidebar

Example: Image Viewer

An image viewer demonstrating image loading, zoom/pan, and thumbnail lists.

Overview

This example builds an image viewer with:

  • ImageWidget for displaying images
  • Zoom and pan controls
  • Thumbnail strip for navigation
  • File open dialog for images
  • Fit-to-window and actual-size modes

Key Concepts

  • ImageWidget: Image display with scaling modes
  • ScrollArea: Panning larger images
  • ListView: Thumbnail strip
  • File dialogs: Opening image files
  • Keyboard shortcuts: Navigation controls

Full Source

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{
    Window, Container, ImageWidget, ScrollArea, ListView, Splitter,
    PushButton, Label, Slider, FileDialog, FileFilter,
    ImageScaleMode
};
use horizon_lattice::widget::layout::{VBoxLayout, HBoxLayout, LayoutKind, ContentMargins};
use horizon_lattice::model::ListModel;
use horizon_lattice::widget::{Widget, WidgetEvent, Key};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};

#[derive(Clone)]
struct ImageEntry {
    path: PathBuf,
    name: String,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("Image Viewer")
        .with_size(1000.0, 700.0);

    // Current state
    let current_index = Arc::new(Mutex::new(0usize));
    let images: Arc<Mutex<Vec<ImageEntry>>> = Arc::new(Mutex::new(Vec::new()));

    // Main image display
    let image_widget = ImageWidget::new();
    image_widget.set_scale_mode(ImageScaleMode::Fit);

    // Wrap in scroll area for panning
    let mut scroll_area = ScrollArea::new();
    scroll_area.set_widget(image_widget.object_id());

    // Zoom controls
    let zoom_slider = Slider::new();
    zoom_slider.set_range(10, 400);  // 10% to 400%
    zoom_slider.set_value(100);

    let zoom_label = Label::new("100%");

    let fit_btn = PushButton::new("Fit");
    let actual_btn = PushButton::new("1:1");

    // Zoom slider updates scale
    let image = image_widget.clone();
    let label = zoom_label.clone();
    zoom_slider.value_changed.connect(move |&value| {
        let scale = value as f32 / 100.0;
        image.set_scale(scale);
        label.set_text(&format!("{}%", value));
    });

    // Fit button
    let image = image_widget.clone();
    let slider = zoom_slider.clone();
    let label = zoom_label.clone();
    fit_btn.clicked().connect(move |_| {
        image.set_scale_mode(ImageScaleMode::Fit);
        slider.set_value(100);
        label.set_text("Fit");
    });

    // Actual size button
    let image = image_widget.clone();
    let slider = zoom_slider.clone();
    let label = zoom_label.clone();
    actual_btn.clicked().connect(move |_| {
        image.set_scale_mode(ImageScaleMode::None);
        image.set_scale(1.0);
        slider.set_value(100);
        label.set_text("100%");
    });

    // Navigation buttons
    let prev_btn = PushButton::new("Previous");
    let next_btn = PushButton::new("Next");
    let open_btn = PushButton::new("Open...");

    // Thumbnail list
    let thumbnail_model = Arc::new(Mutex::new(ListModel::new(Vec::<String>::new())));
    let thumbnail_list = ListView::new()
        .with_model(thumbnail_model.lock().unwrap().clone());

    // Open button - load images
    let image = image_widget.clone();
    let imgs = images.clone();
    let idx = current_index.clone();
    let thumbs = thumbnail_model.clone();
    open_btn.clicked().connect(move |_| {
        let filters = vec![
            FileFilter::image_files(),
            FileFilter::all_files(),
        ];

        if let Some(paths) = FileDialog::get_open_file_names("Open Images", "", &filters) {
            let entries: Vec<ImageEntry> = paths.into_iter().map(|p| {
                let name = p.file_name()
                    .and_then(|n| n.to_str())
                    .unwrap_or("Unknown")
                    .to_string();
                ImageEntry { path: p, name }
            }).collect();

            if !entries.is_empty() {
                // Update thumbnail list
                let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
                thumbs.lock().unwrap().set_items(names);

                // Load first image
                image.set_source_file(&entries[0].path);

                *imgs.lock().unwrap() = entries;
                *idx.lock().unwrap() = 0;
            }
        }
    });

    // Previous button
    let image = image_widget.clone();
    let imgs = images.clone();
    let idx = current_index.clone();
    prev_btn.clicked().connect(move |_| {
        let entries = imgs.lock().unwrap();
        let mut i = idx.lock().unwrap();
        if !entries.is_empty() && *i > 0 {
            *i -= 1;
            image.set_source_file(&entries[*i].path);
        }
    });

    // Next button
    let image = image_widget.clone();
    let imgs = images.clone();
    let idx = current_index.clone();
    next_btn.clicked().connect(move |_| {
        let entries = imgs.lock().unwrap();
        let mut i = idx.lock().unwrap();
        if !entries.is_empty() && *i < entries.len() - 1 {
            *i += 1;
            image.set_source_file(&entries[*i].path);
        }
    });

    // Thumbnail click
    let image = image_widget.clone();
    let imgs = images.clone();
    let idx = current_index.clone();
    thumbnail_list.clicked.connect(move |index| {
        let entries = imgs.lock().unwrap();
        let row = index.row() as usize;
        if row < entries.len() {
            image.set_source_file(&entries[row].path);
            *idx.lock().unwrap() = row;
        }
    });

    // Toolbar
    let mut toolbar = HBoxLayout::new();
    toolbar.set_spacing(8.0);
    toolbar.add_widget(open_btn.object_id());
    toolbar.add_widget(prev_btn.object_id());
    toolbar.add_widget(next_btn.object_id());
    toolbar.add_stretch(1);
    toolbar.add_widget(fit_btn.object_id());
    toolbar.add_widget(actual_btn.object_id());
    toolbar.add_widget(zoom_slider.object_id());
    toolbar.add_widget(zoom_label.object_id());

    let mut toolbar_container = Container::new();
    toolbar_container.set_layout(LayoutKind::from(toolbar));

    // Main content with splitter
    let mut splitter = Splitter::new();
    splitter.add_widget(scroll_area.object_id());
    splitter.add_widget(thumbnail_list.object_id());
    splitter.set_sizes(&[700, 200]);

    // Main layout
    let mut layout = VBoxLayout::new();
    layout.set_content_margins(ContentMargins::uniform(8.0));
    layout.set_spacing(8.0);
    layout.add_widget(toolbar_container.object_id());
    layout.add_widget(splitter.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(layout));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

Features Demonstrated

FeatureDescription
ImageWidgetImage display with scaling
ScrollAreaPan large images
SliderZoom control
ListViewThumbnail strip
SplitterResizable panels
FileDialogMulti-file selection

Exercises

  1. Add keyboard navigation: Arrow keys for prev/next
  2. Add mouse wheel zoom: Zoom centered on cursor
  3. Add drag-to-pan: Click and drag to pan
  4. Add slideshow mode: Auto-advance with timer
  5. Add image info: Show dimensions, file size, date

Example: Settings Dialog

A settings dialog demonstrating TabWidget, form inputs, and preferences management.

Overview

This example builds a settings dialog with:

  • TabWidget for organized settings categories
  • Various input widgets (CheckBox, ComboBox, SpinBox, etc.)
  • Apply/OK/Cancel buttons with standard behavior
  • Settings persistence with auto-save

Key Concepts

  • TabWidget: Organize settings into categories
  • FormLayout: Label-field arrangement
  • Dialog: Modal dialog with accept/reject
  • Settings: Persistent preferences storage
  • Validation: Input validation before saving

Full Source

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{
    Dialog, TabWidget, Container, Label, CheckBox, ComboBox, SpinBox,
    LineEdit, PushButton, GroupBox, ColorButton, FontComboBox,
    ButtonVariant
};
use horizon_lattice::widget::layout::{
    VBoxLayout, HBoxLayout, FormLayout, ContentMargins, LayoutKind
};
use horizon_lattice::file::{Settings, SettingsFormat, path::AppPaths};
use horizon_lattice::render::Color;
use std::sync::{Arc, Mutex};

fn create_general_tab(settings: &Settings) -> Container {
    let mut form = FormLayout::new();

    // Language selection
    let language = ComboBox::new();
    language.add_items(&["English", "Spanish", "French", "German", "Japanese"]);
    language.set_current_index(settings.get_or("general.language", 0));
    form.add_row(Label::new("Language:"), language);

    // Startup behavior
    let restore_session = CheckBox::new("Restore previous session on startup");
    restore_session.set_checked(settings.get_or("general.restore_session", true));
    form.add_spanning_widget(restore_session);

    let check_updates = CheckBox::new("Check for updates automatically");
    check_updates.set_checked(settings.get_or("general.check_updates", true));
    form.add_spanning_widget(check_updates);

    // Recent files limit
    let recent_limit = SpinBox::new();
    recent_limit.set_range(0, 50);
    recent_limit.set_value(settings.get_or("general.recent_limit", 10));
    form.add_row(Label::new("Recent files limit:"), recent_limit);

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(form));
    container
}

fn create_appearance_tab(settings: &Settings) -> Container {
    let mut layout = VBoxLayout::new();
    layout.set_spacing(16.0);

    // Theme group
    let mut theme_form = FormLayout::new();

    let theme = ComboBox::new();
    theme.add_items(&["System", "Light", "Dark", "High Contrast"]);
    theme.set_current_index(settings.get_or("appearance.theme", 0));
    theme_form.add_row(Label::new("Theme:"), theme);

    let accent_color = ColorButton::new();
    accent_color.set_color(Color::from_hex(
        &settings.get_or("appearance.accent", "#0078D4".to_string())
    ).unwrap_or(Color::from_rgb8(0, 120, 212)));
    theme_form.add_row(Label::new("Accent color:"), accent_color);

    let mut theme_group = GroupBox::new("Theme");
    theme_group.set_layout(LayoutKind::from(theme_form));
    layout.add_widget(theme_group.object_id());

    // Font group
    let mut font_form = FormLayout::new();

    let font_family = FontComboBox::new();
    // font_family.set_current_font(settings.get_or("appearance.font", "System".to_string()));
    font_form.add_row(Label::new("Font:"), font_family);

    let font_size = SpinBox::new();
    font_size.set_range(8, 72);
    font_size.set_value(settings.get_or("appearance.font_size", 12));
    font_form.add_row(Label::new("Size:"), font_size);

    let mut font_group = GroupBox::new("Font");
    font_group.set_layout(LayoutKind::from(font_form));
    layout.add_widget(font_group.object_id());

    layout.add_stretch(1);

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(layout));
    container
}

fn create_editor_tab(settings: &Settings) -> Container {
    let mut layout = VBoxLayout::new();
    layout.set_spacing(12.0);

    // Editor options
    let word_wrap = CheckBox::new("Enable word wrap");
    word_wrap.set_checked(settings.get_or("editor.word_wrap", true));
    layout.add_widget(word_wrap.object_id());

    let line_numbers = CheckBox::new("Show line numbers");
    line_numbers.set_checked(settings.get_or("editor.line_numbers", true));
    layout.add_widget(line_numbers.object_id());

    let highlight_line = CheckBox::new("Highlight current line");
    highlight_line.set_checked(settings.get_or("editor.highlight_line", true));
    layout.add_widget(highlight_line.object_id());

    let auto_indent = CheckBox::new("Auto-indent");
    auto_indent.set_checked(settings.get_or("editor.auto_indent", true));
    layout.add_widget(auto_indent.object_id());

    // Tab settings
    let mut tab_form = FormLayout::new();

    let tab_size = SpinBox::new();
    tab_size.set_range(1, 8);
    tab_size.set_value(settings.get_or("editor.tab_size", 4));
    tab_form.add_row(Label::new("Tab size:"), tab_size);

    let use_spaces = CheckBox::new("Insert spaces instead of tabs");
    use_spaces.set_checked(settings.get_or("editor.use_spaces", true));
    tab_form.add_spanning_widget(use_spaces);

    let mut tab_group = GroupBox::new("Indentation");
    tab_group.set_layout(LayoutKind::from(tab_form));
    layout.add_widget(tab_group.object_id());

    layout.add_stretch(1);

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(layout));
    container
}

fn create_advanced_tab(settings: &Settings) -> Container {
    let mut layout = VBoxLayout::new();
    layout.set_spacing(12.0);

    // Performance
    let mut perf_form = FormLayout::new();

    let max_recent = SpinBox::new();
    max_recent.set_range(100, 10000);
    max_recent.set_value(settings.get_or("advanced.max_undo", 1000));
    perf_form.add_row(Label::new("Max undo history:"), max_recent);

    let auto_save = CheckBox::new("Auto-save files");
    auto_save.set_checked(settings.get_or("advanced.auto_save", false));
    perf_form.add_spanning_widget(auto_save);

    let auto_save_interval = SpinBox::new();
    auto_save_interval.set_range(1, 60);
    auto_save_interval.set_value(settings.get_or("advanced.auto_save_interval", 5));
    auto_save_interval.set_suffix(" min");
    perf_form.add_row(Label::new("Auto-save interval:"), auto_save_interval);

    let mut perf_group = GroupBox::new("Performance");
    perf_group.set_layout(LayoutKind::from(perf_form));
    layout.add_widget(perf_group.object_id());

    // Reset button
    let reset_btn = PushButton::new("Reset to Defaults")
        .with_variant(ButtonVariant::Danger);
    layout.add_widget(reset_btn.object_id());

    layout.add_stretch(1);

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(layout));
    container
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    // Load settings
    let app_paths = AppPaths::new("com", "example", "settings-demo")?;
    let settings_path = app_paths.config().join("settings.json");

    let settings = if settings_path.exists() {
        Settings::load_json(&settings_path).unwrap_or_else(|_| Settings::new())
    } else {
        Settings::new()
    };

    let settings = Arc::new(settings);

    // Create dialog
    let mut dialog = Dialog::new("Settings")
        .with_size(500.0, 450.0);

    // Tab widget
    let mut tabs = TabWidget::new();

    tabs.add_tab("General", create_general_tab(&settings));
    tabs.add_tab("Appearance", create_appearance_tab(&settings));
    tabs.add_tab("Editor", create_editor_tab(&settings));
    tabs.add_tab("Advanced", create_advanced_tab(&settings));

    // Button row
    let ok_btn = PushButton::new("OK")
        .with_variant(ButtonVariant::Primary)
        .with_default(true);
    let cancel_btn = PushButton::new("Cancel");
    let apply_btn = PushButton::new("Apply");

    // OK button - save and close
    let dlg = dialog.clone();
    let s = settings.clone();
    let path = settings_path.clone();
    ok_btn.clicked().connect(move |_| {
        // Would collect values from all widgets and save
        let _ = s.save_json(&path);
        dlg.accept();
    });

    // Cancel button - close without saving
    let dlg = dialog.clone();
    cancel_btn.clicked().connect(move |_| {
        dlg.reject();
    });

    // Apply button - save without closing
    let s = settings.clone();
    let path = settings_path.clone();
    apply_btn.clicked().connect(move |_| {
        // Would collect values from all widgets and save
        let _ = s.save_json(&path);
    });

    let mut button_row = HBoxLayout::new();
    button_row.set_spacing(8.0);
    button_row.add_stretch(1);
    button_row.add_widget(ok_btn.object_id());
    button_row.add_widget(cancel_btn.object_id());
    button_row.add_widget(apply_btn.object_id());

    let mut button_container = Container::new();
    button_container.set_layout(LayoutKind::from(button_row));

    // Main layout
    let mut layout = VBoxLayout::new();
    layout.set_content_margins(ContentMargins::uniform(16.0));
    layout.set_spacing(16.0);
    layout.add_widget(tabs.object_id());
    layout.add_widget(button_container.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(layout));

    dialog.set_content_widget(container.object_id());
    dialog.open();

    app.run()
}

Features Demonstrated

FeatureDescription
TabWidgetOrganize settings into tabs
FormLayoutLabel-field arrangements
GroupBoxTitled setting groups
DialogModal dialog with OK/Cancel
Various inputsCheckBox, ComboBox, SpinBox, etc.
SettingsPersistent preferences

Exercises

  1. Add validation: Validate settings before saving
  2. Add import/export: Import/export settings to file
  3. Add search: Search for settings by name
  4. Add keyboard shortcuts tab: Configure shortcuts
  5. Add preview: Live preview of appearance changes

Example: Network Client

A network client demonstrating async HTTP requests, threading, and live updates.

Overview

This example builds a REST API client with:

  • Async HTTP requests using the thread pool
  • Live response display with syntax highlighting
  • Request history with caching
  • Progress indication for long requests

Key Concepts

  • ThreadPool: Background HTTP requests
  • Worker: Long-running network operations
  • Signals: Progress and completion updates
  • TreeView: Request history

Full Source

use horizon_lattice::Application;
use horizon_lattice::widget::widgets::{
    Window, Container, Label, TextEdit, PushButton, ComboBox,
    LineEdit, TreeView, Splitter, ProgressBar, TabWidget,
    ButtonVariant, GroupBox
};
use horizon_lattice::widget::layout::{
    VBoxLayout, HBoxLayout, FormLayout, LayoutKind, ContentMargins
};
use horizon_lattice::concurrent::{ThreadPool, Worker, CancellationToken};
use horizon_lattice::model::{TreeModel, TreeNode};
use std::sync::{Arc, Mutex};
use std::collections::HashMap;
use std::time::{Duration, Instant};

#[derive(Clone)]
struct RequestEntry {
    method: String,
    url: String,
    status: Option<u16>,
    duration_ms: u64,
    response: String,
    timestamp: String,
}

struct HttpClient {
    timeout: Duration,
}

impl HttpClient {
    fn new() -> Self {
        Self {
            timeout: Duration::from_secs(30),
        }
    }

    fn request(&self, method: &str, url: &str, body: Option<&str>)
        -> Result<(u16, String, Duration), String>
    {
        let start = Instant::now();

        // Simulated HTTP request - in real implementation, use reqwest or ureq
        // This is a placeholder for demonstration
        std::thread::sleep(Duration::from_millis(500));

        let response = match method {
            "GET" => format!(r#"{{"message": "GET response from {}"}}"#, url),
            "POST" => format!(r#"{{"message": "Created", "data": {}}}"#, body.unwrap_or("{}")),
            "PUT" => format!(r#"{{"message": "Updated"}}"#),
            "DELETE" => format!(r#"{{"message": "Deleted"}}"#),
            _ => r#"{"error": "Unknown method"}"#.to_string(),
        };

        Ok((200, response, start.elapsed()))
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new()?;

    let mut window = Window::new("HTTP Client")
        .with_size(1000.0, 700.0);

    let pool = ThreadPool::new(4);
    let history: Arc<Mutex<Vec<RequestEntry>>> = Arc::new(Mutex::new(Vec::new()));
    let current_cancel: Arc<Mutex<Option<CancellationToken>>> = Arc::new(Mutex::new(None));

    // Request builder section
    let method_combo = ComboBox::new();
    method_combo.add_items(&["GET", "POST", "PUT", "DELETE", "PATCH"]);

    let url_input = LineEdit::new();
    url_input.set_placeholder("https://api.example.com/endpoint");

    let send_btn = PushButton::new("Send")
        .with_variant(ButtonVariant::Primary);
    let cancel_btn = PushButton::new("Cancel");
    cancel_btn.set_enabled(false);

    // Headers
    let mut headers_form = FormLayout::new();
    let content_type = ComboBox::new();
    content_type.add_items(&["application/json", "text/plain", "application/xml"]);
    headers_form.add_row(Label::new("Content-Type:"), content_type.clone());

    let auth_header = LineEdit::new();
    auth_header.set_placeholder("Bearer token...");
    headers_form.add_row(Label::new("Authorization:"), auth_header);

    let mut headers_group = GroupBox::new("Headers");
    headers_group.set_layout(LayoutKind::from(headers_form));

    // Request body
    let body_edit = TextEdit::new();
    body_edit.set_placeholder("Request body (JSON)...");

    // Response display
    let response_edit = TextEdit::new();
    response_edit.set_read_only(true);

    let status_label = Label::new("Ready");
    let progress = ProgressBar::new();
    progress.set_range(0, 100);
    progress.set_value(0);

    // History tree
    let history_model: Arc<Mutex<TreeModel<String>>> =
        Arc::new(Mutex::new(TreeModel::new()));
    let history_tree = TreeView::new()
        .with_model(history_model.lock().unwrap().clone());

    // URL bar layout
    let mut url_row = HBoxLayout::new();
    url_row.set_spacing(4.0);
    url_row.add_widget(method_combo.object_id());
    url_row.add_widget(url_input.object_id());
    url_row.add_widget(send_btn.object_id());
    url_row.add_widget(cancel_btn.object_id());

    let mut url_container = Container::new();
    url_container.set_layout(LayoutKind::from(url_row));

    // Tabs for body/headers
    let mut tabs = TabWidget::new();

    let mut body_container = Container::new();
    let mut body_layout = VBoxLayout::new();
    body_layout.add_widget(body_edit.object_id());
    body_container.set_layout(LayoutKind::from(body_layout));

    tabs.add_tab("Body", body_container);
    tabs.add_tab("Headers", headers_group);

    // Request panel
    let mut request_layout = VBoxLayout::new();
    request_layout.set_spacing(8.0);
    request_layout.add_widget(url_container.object_id());
    request_layout.add_widget(tabs.object_id());

    let mut request_panel = Container::new();
    request_panel.set_layout(LayoutKind::from(request_layout));

    // Response panel
    let mut response_layout = VBoxLayout::new();
    response_layout.set_spacing(8.0);
    response_layout.add_widget(status_label.object_id());
    response_layout.add_widget(progress.object_id());
    response_layout.add_widget(response_edit.object_id());

    let mut response_panel = Container::new();
    response_panel.set_layout(LayoutKind::from(response_layout));

    // Send button handler
    let url = url_input.clone();
    let method = method_combo.clone();
    let body = body_edit.clone();
    let response = response_edit.clone();
    let status = status_label.clone();
    let prog = progress.clone();
    let send = send_btn.clone();
    let cancel = cancel_btn.clone();
    let cancel_token = current_cancel.clone();
    let hist = history.clone();
    let hist_model = history_model.clone();
    let pool_clone = pool.clone();

    send_btn.clicked().connect(move |_| {
        let url_text = url.text();
        let method_text = method.current_text();
        let body_text = body.text();

        if url_text.is_empty() {
            status.set_text("Please enter a URL");
            return;
        }

        // Disable send, enable cancel
        send.set_enabled(false);
        cancel.set_enabled(true);
        status.set_text("Sending request...");
        prog.set_value(0);

        // Create cancellation token
        let token = CancellationToken::new();
        *cancel_token.lock().unwrap() = Some(token.clone());

        // Clone for closure
        let response_clone = response.clone();
        let status_clone = status.clone();
        let prog_clone = prog.clone();
        let send_clone = send.clone();
        let cancel_clone = cancel.clone();
        let hist_clone = hist.clone();
        let hist_model_clone = hist_model.clone();
        let method_for_history = method_text.clone();
        let url_for_history = url_text.clone();

        pool_clone.spawn(move || {
            let client = HttpClient::new();

            // Simulate progress
            for i in 0..5 {
                if token.is_cancelled() {
                    return;
                }
                std::thread::sleep(Duration::from_millis(100));
                prog_clone.set_value((i + 1) * 20);
            }

            let body_opt = if body_text.is_empty() {
                None
            } else {
                Some(body_text.as_str())
            };

            match client.request(&method_for_history, &url_for_history, body_opt) {
                Ok((code, body, duration)) => {
                    // Format JSON for display
                    let formatted = body; // Could use serde_json for pretty printing

                    response_clone.set_text(&formatted);
                    status_clone.set_text(&format!(
                        "Status: {} | Time: {}ms",
                        code,
                        duration.as_millis()
                    ));

                    // Add to history
                    let entry = RequestEntry {
                        method: method_for_history.clone(),
                        url: url_for_history.clone(),
                        status: Some(code),
                        duration_ms: duration.as_millis() as u64,
                        response: formatted,
                        timestamp: "now".to_string(), // Would use actual timestamp
                    };
                    hist_clone.lock().unwrap().push(entry);

                    // Update tree model
                    let label = format!("{} {} - {}ms",
                        method_for_history, url_for_history, duration.as_millis());
                    hist_model_clone.lock().unwrap().add_root(label);
                }
                Err(e) => {
                    response_clone.set_text(&format!("Error: {}", e));
                    status_clone.set_text("Request failed");
                }
            }

            prog_clone.set_value(100);
            send_clone.set_enabled(true);
            cancel_clone.set_enabled(false);
        });
    });

    // Cancel button handler
    let cancel_token = current_cancel.clone();
    let send = send_btn.clone();
    let cancel = cancel_btn.clone();
    let status = status_label.clone();

    cancel_btn.clicked().connect(move |_| {
        if let Some(token) = cancel_token.lock().unwrap().take() {
            token.cancel();
            status.set_text("Request cancelled");
            send.set_enabled(true);
            cancel.set_enabled(false);
        }
    });

    // History item click - load previous request
    let response = response_edit.clone();
    let hist = history.clone();

    history_tree.clicked.connect(move |index| {
        let entries = hist.lock().unwrap();
        if let Some(entry) = entries.get(index.row() as usize) {
            response.set_text(&entry.response);
        }
    });

    // Main splitter: request/response on top, history on bottom
    let mut top_splitter = Splitter::horizontal();
    top_splitter.add_widget(request_panel.object_id());
    top_splitter.add_widget(response_panel.object_id());
    top_splitter.set_sizes(&[500, 500]);

    let mut main_splitter = Splitter::vertical();
    main_splitter.add_widget(top_splitter.object_id());
    main_splitter.add_widget(history_tree.object_id());
    main_splitter.set_sizes(&[500, 200]);

    // Main layout
    let mut layout = VBoxLayout::new();
    layout.set_content_margins(ContentMargins::uniform(8.0));
    layout.add_widget(main_splitter.object_id());

    let mut container = Container::new();
    container.set_layout(LayoutKind::from(layout));

    window.set_content_widget(container.object_id());
    window.show();

    app.run()
}

Features Demonstrated

FeatureDescription
ThreadPoolBackground HTTP requests
CancellationTokenCancel in-flight requests
ComboBoxHTTP method selection
TextEditRequest body and response display
ProgressBarRequest progress indication
TreeViewRequest history
SplitterResizable panels
TabWidgetBody/Headers tabs

HTTP Client Patterns

Async Request Pattern

use horizon_lattice::concurrent::{ThreadPool, CancellationToken};

let pool = ThreadPool::new(4);
let token = CancellationToken::new();

// Store token for cancellation
let token_for_cancel = token.clone();

pool.spawn(move || {
    // Check cancellation periodically
    for _ in 0..10 {
        if token.is_cancelled() {
            return;
        }
        // Do work...
    }
});

// Later, cancel if needed
token_for_cancel.cancel();

Progress Reporting

use horizon_lattice::concurrent::{ThreadPool, ProgressReporter};

let pool = ThreadPool::new(4);
let (reporter, receiver) = ProgressReporter::new();

pool.spawn(move || {
    for i in 0..100 {
        reporter.set_progress(i as f32 / 100.0);
        // Do work...
    }
});

// In UI thread
receiver.progress_changed.connect(|&progress| {
    progress_bar.set_value((progress * 100.0) as i32);
});

Exercises

  1. Add request persistence: Save/load request collections
  2. Add response formatting: Pretty-print JSON/XML
  3. Add authentication presets: OAuth, Basic Auth, API Key
  4. Add environment variables: Variable substitution in URLs
  5. Add response testing: Assertions on response data

Widget Catalog

A comprehensive reference of all built-in widgets in Horizon Lattice.

Basic Widgets

Label

Displays read-only text with optional rich text formatting.

use horizon_lattice::widget::widgets::Label;

let label = Label::new("Hello, World!");
label.set_alignment(TextAlign::Center);
label.set_word_wrap(true);
label.set_selectable(true);  // Allow text selection

Key Properties:

  • text - The displayed text
  • alignment - Text alignment (Left, Center, Right)
  • word_wrap - Enable word wrapping
  • selectable - Allow text selection
  • elide_mode - How to elide overflow (None, Left, Middle, Right)

PushButton

A clickable push button with optional icon.

use horizon_lattice::widget::widgets::{PushButton, ButtonVariant};

let button = PushButton::new("Click Me")
    .with_variant(ButtonVariant::Primary)
    .with_icon(Icon::from_name("check"))
    .with_default(true);  // Responds to Enter key

button.clicked().connect(|_| println!("Clicked!"));

Variants:

  • Primary - Prominent action button
  • Secondary - Default button style
  • Outlined - Border-only button
  • Text - Text-only button
  • Danger - Destructive action button

Signals:

  • clicked - Emitted when button is clicked
  • pressed - Emitted when button is pressed down
  • released - Emitted when button is released

CheckBox

A toggleable checkbox with optional label.

use horizon_lattice::widget::widgets::CheckBox;

let checkbox = CheckBox::new("Enable feature");
checkbox.set_tristate(true);  // Allow indeterminate state

checkbox.toggled().connect(|&checked| {
    println!("Checked: {}", checked);
});

Properties:

  • text - Checkbox label
  • checked - Current checked state
  • tristate - Enable three-state mode (checked, unchecked, indeterminate)
  • check_state - Full state (Unchecked, PartiallyChecked, Checked)

RadioButton

Mutually exclusive option buttons within a group.

use horizon_lattice::widget::widgets::{RadioButton, ButtonGroup};

let mut group = ButtonGroup::new();
let opt_a = RadioButton::new("Option A");
let opt_b = RadioButton::new("Option B");
let opt_c = RadioButton::new("Option C");

group.add_button(opt_a.clone());
group.add_button(opt_b.clone());
group.add_button(opt_c.clone());

group.button_clicked.connect(|id| {
    println!("Selected option: {}", id);
});

ToolButton

Compact button typically used in toolbars.

use horizon_lattice::widget::widgets::{ToolButton, ToolButtonStyle};

let tool_btn = ToolButton::new()
    .with_icon(Icon::from_name("bold"))
    .with_text("Bold")
    .with_style(ToolButtonStyle::IconOnly)
    .with_checkable(true);

Styles:

  • IconOnly - Show only icon
  • TextOnly - Show only text
  • TextBesideIcon - Text next to icon
  • TextUnderIcon - Text below icon

Input Widgets

LineEdit

Single-line text input field.

use horizon_lattice::widget::widgets::{LineEdit, EchoMode};

let edit = LineEdit::new();
edit.set_placeholder("Enter your name...");
edit.set_max_length(50);
edit.set_echo_mode(EchoMode::Password);

edit.text_changed.connect(|text| {
    println!("Text: {}", text);
});

edit.return_pressed.connect(|| {
    println!("Enter pressed!");
});

Echo Modes:

  • Normal - Display text as entered
  • Password - Display bullets/asterisks
  • NoEcho - Display nothing
  • PasswordEchoOnEdit - Show while typing

TextEdit

Multi-line text editing widget with rich text support.

use horizon_lattice::widget::widgets::TextEdit;

let editor = TextEdit::new();
editor.set_text("Initial content");
editor.set_word_wrap(true);
editor.set_read_only(false);
editor.set_tab_stop_width(4);

// Editing operations
editor.undo();
editor.redo();
editor.cut();
editor.copy();
editor.paste();
editor.select_all();

Signals:

  • text_changed - Content changed
  • cursor_position_changed - Cursor moved
  • selection_changed - Selection changed

SpinBox

Numeric input with increment/decrement buttons.

use horizon_lattice::widget::widgets::SpinBox;

let spin = SpinBox::new();
spin.set_range(0, 100);
spin.set_value(50);
spin.set_step(5);
spin.set_prefix("$");
spin.set_suffix(" USD");
spin.set_wrapping(true);  // Wrap around at limits

spin.value_changed.connect(|&value| {
    println!("Value: {}", value);
});

DoubleSpinBox

Floating-point numeric input.

use horizon_lattice::widget::widgets::DoubleSpinBox;

let spin = DoubleSpinBox::new();
spin.set_range(0.0, 1.0);
spin.set_value(0.5);
spin.set_decimals(2);
spin.set_step(0.1);

Slider

Continuous value selection via draggable handle.

use horizon_lattice::widget::widgets::{Slider, Orientation};

let slider = Slider::new();
slider.set_orientation(Orientation::Horizontal);
slider.set_range(0, 100);
slider.set_value(50);
slider.set_tick_position(TickPosition::Below);
slider.set_tick_interval(10);

slider.value_changed.connect(|&value| {
    println!("Slider: {}", value);
});

ComboBox

Dropdown selection widget.

use horizon_lattice::widget::widgets::ComboBox;

let combo = ComboBox::new();
combo.add_items(&["Option 1", "Option 2", "Option 3"]);
combo.set_current_index(0);
combo.set_editable(true);  // Allow custom input
combo.set_placeholder("Select...");

combo.current_index_changed.connect(|&index| {
    println!("Selected index: {}", index);
});

DateEdit / TimeEdit / DateTimeEdit

Date and time input widgets.

use horizon_lattice::widget::widgets::{DateEdit, TimeEdit, DateTimeEdit};
use horizon_lattice::core::{Date, Time, DateTime};

let date = DateEdit::new();
date.set_date(Date::today());
date.set_display_format("yyyy-MM-dd");
date.set_calendar_popup(true);

let time = TimeEdit::new();
time.set_time(Time::current());
time.set_display_format("HH:mm:ss");

let datetime = DateTimeEdit::new();
datetime.set_datetime(DateTime::now());

ColorButton

Color selection button with color picker dialog.

use horizon_lattice::widget::widgets::ColorButton;
use horizon_lattice::render::Color;

let color_btn = ColorButton::new();
color_btn.set_color(Color::from_rgb8(255, 128, 0));
color_btn.set_show_alpha(true);

color_btn.color_changed.connect(|color| {
    println!("Selected: {:?}", color);
});

FontComboBox

Font family selection.

use horizon_lattice::widget::widgets::FontComboBox;

let font_combo = FontComboBox::new();
font_combo.set_current_font("Helvetica");

font_combo.font_changed.connect(|family| {
    println!("Font: {}", family);
});

Container Widgets

Container

Generic container for arranging child widgets with a layout.

use horizon_lattice::widget::widgets::Container;
use horizon_lattice::widget::layout::{VBoxLayout, LayoutKind};

let mut container = Container::new();
let mut layout = VBoxLayout::new();
layout.add_widget(widget1.object_id());
layout.add_widget(widget2.object_id());
container.set_layout(LayoutKind::from(layout));

ScrollArea

Scrollable container for content larger than visible area.

use horizon_lattice::widget::widgets::{ScrollArea, ScrollBarPolicy};

let mut scroll = ScrollArea::new();
scroll.set_widget(content.object_id());
scroll.set_horizontal_policy(ScrollBarPolicy::AsNeeded);
scroll.set_vertical_policy(ScrollBarPolicy::AlwaysOn);
scroll.set_widget_resizable(true);

// Programmatic scrolling
scroll.scroll_to(0, 500);
scroll.ensure_visible(widget.object_id());

TabWidget

Tabbed container showing one page at a time.

use horizon_lattice::widget::widgets::TabWidget;

let mut tabs = TabWidget::new();
tabs.add_tab("General", general_page);
tabs.add_tab("Advanced", advanced_page);
tabs.set_tab_position(TabPosition::Top);
tabs.set_tabs_closable(true);
tabs.set_movable(true);

tabs.current_changed.connect(|&index| {
    println!("Tab switched to: {}", index);
});

tabs.tab_close_requested.connect(|&index| {
    tabs.remove_tab(index);
});

Splitter

Resizable split view between widgets.

use horizon_lattice::widget::widgets::Splitter;

let mut splitter = Splitter::new();
splitter.add_widget(left_panel.object_id());
splitter.add_widget(right_panel.object_id());
splitter.set_sizes(&[200, 400]);
splitter.set_collapsible(0, true);  // First panel can collapse

splitter.splitter_moved.connect(|&(pos, index)| {
    println!("Splitter {} moved to {}", index, pos);
});

GroupBox

Titled container with optional checkbox.

use horizon_lattice::widget::widgets::GroupBox;

let mut group = GroupBox::new("Options");
group.set_checkable(true);
group.set_checked(true);
group.set_layout(LayoutKind::from(layout));

group.toggled.connect(|&enabled| {
    println!("Group enabled: {}", enabled);
});

StackWidget

Shows one widget at a time, like a deck of cards.

use horizon_lattice::widget::widgets::StackWidget;

let mut stack = StackWidget::new();
stack.add_widget(page1.object_id());
stack.add_widget(page2.object_id());
stack.add_widget(page3.object_id());
stack.set_current_index(0);

// Switch pages
stack.set_current_widget(page2.object_id());

Display Widgets

ProgressBar

Progress indication for long operations.

use horizon_lattice::widget::widgets::ProgressBar;

let progress = ProgressBar::new();
progress.set_range(0, 100);
progress.set_value(50);
progress.set_text_visible(true);
progress.set_format("%v / %m (%p%)");

// Indeterminate mode
progress.set_range(0, 0);

ImageWidget

Image display with scaling modes.

use horizon_lattice::widget::widgets::{ImageWidget, ImageScaleMode};

let image = ImageWidget::new();
image.set_source_file("photo.jpg");
image.set_scale_mode(ImageScaleMode::Fit);
image.set_alignment(Alignment::Center);

// Or from data
image.set_source_data(&image_bytes, "png");

Scale Modes:

  • None - Display at actual size
  • Fit - Scale to fit, preserve aspect ratio
  • Fill - Scale to fill, may crop
  • Stretch - Stretch to fill, ignore aspect ratio
  • Tile - Tile the image

ListView

List data display with item selection.

use horizon_lattice::widget::widgets::ListView;
use horizon_lattice::model::{ListModel, SelectionMode};

let model = ListModel::new(vec!["Item 1", "Item 2", "Item 3"]);
let list = ListView::new().with_model(model);
list.set_selection_mode(SelectionMode::Extended);

list.clicked.connect(|index| {
    println!("Clicked row: {}", index.row());
});

list.double_clicked.connect(|index| {
    println!("Double-clicked: {}", index.row());
});

TreeView

Hierarchical data display with expandable nodes.

use horizon_lattice::widget::widgets::TreeView;
use horizon_lattice::model::TreeModel;

let model = TreeModel::new();
let root = model.add_root("Root");
model.add_child(root, "Child 1");
model.add_child(root, "Child 2");

let tree = TreeView::new().with_model(model);
tree.set_root_decorated(true);
tree.set_items_expandable(true);

tree.expanded.connect(|index| {
    println!("Expanded: {:?}", index);
});

TableView

Tabular data display with rows and columns.

use horizon_lattice::widget::widgets::TableView;
use horizon_lattice::model::TableModel;

let model = TableModel::new(vec![
    vec!["A1", "B1", "C1"],
    vec!["A2", "B2", "C2"],
]);
model.set_headers(&["Column A", "Column B", "Column C"]);

let table = TableView::new().with_model(model);
table.set_column_width(0, 100);
table.set_row_height(30);
table.set_grid_visible(true);
table.set_alternating_row_colors(true);

Window Widgets

Window

Basic application window.

use horizon_lattice::widget::widgets::Window;

let mut window = Window::new("My App")
    .with_size(800.0, 600.0)
    .with_position(100.0, 100.0);

window.set_content_widget(content.object_id());
window.show();

MainWindow

Application window with menu bar, toolbars, and status bar.

use horizon_lattice::widget::widgets::{MainWindow, MenuBar, ToolBar, StatusBar};

let mut main = MainWindow::new("My App")
    .with_size(1024.0, 768.0);

main.set_menu_bar(menu_bar);
main.add_tool_bar(tool_bar);
main.set_central_widget(content.object_id());
main.set_status_bar(status_bar);
main.show();

Dialog

Modal dialog window.

use horizon_lattice::widget::widgets::{Dialog, DialogButtonBox, StandardButton};

let mut dialog = Dialog::new("Confirm")
    .with_size(400.0, 200.0);

let buttons = DialogButtonBox::new()
    .with_standard_buttons(StandardButton::Ok | StandardButton::Cancel);

dialog.set_content_widget(content.object_id());
dialog.set_button_box(buttons);

// Show modally and get result
match dialog.exec() {
    DialogResult::Accepted => println!("OK clicked"),
    DialogResult::Rejected => println!("Cancelled"),
}

// Or show non-modally
dialog.open();
dialog.accepted.connect(|| { /* ... */ });
dialog.rejected.connect(|| { /* ... */ });

MessageBox

Standard message dialogs.

use horizon_lattice::widget::widgets::{MessageBox, MessageIcon, StandardButton};

// Information message
MessageBox::information("Info", "Operation completed successfully.");

// Question with buttons
let result = MessageBox::question(
    "Confirm",
    "Are you sure you want to delete this file?",
    StandardButton::Yes | StandardButton::No
);

// Warning
MessageBox::warning("Warning", "This action cannot be undone.");

// Error
MessageBox::critical("Error", "Failed to save file.");

FileDialog

Native file open/save dialogs.

use horizon_lattice::widget::widgets::{FileDialog, FileFilter};

// Open single file
let filters = vec![
    FileFilter::new("Images", &["png", "jpg", "gif"]),
    FileFilter::all_files(),
];

if let Some(path) = FileDialog::get_open_file_name("Open Image", "", &filters) {
    println!("Selected: {:?}", path);
}

// Open multiple files
if let Some(paths) = FileDialog::get_open_file_names("Open Files", "", &filters) {
    for path in paths {
        println!("Selected: {:?}", path);
    }
}

// Save file
if let Some(path) = FileDialog::get_save_file_name("Save As", "", &filters) {
    println!("Save to: {:?}", path);
}

// Select directory
if let Some(dir) = FileDialog::get_existing_directory("Choose Directory", "") {
    println!("Directory: {:?}", dir);
}

Application menu bar.

use horizon_lattice::widget::widgets::{MenuBar, Menu};

let mut menu_bar = MenuBar::new();
menu_bar.add_menu(file_menu);
menu_bar.add_menu(edit_menu);
menu_bar.add_menu(help_menu);

Dropdown menu with actions and submenus.

use horizon_lattice::widget::widgets::{Menu, Action};

let mut file_menu = Menu::new("File");
file_menu.add_action(Action::new("New").with_shortcut("Ctrl+N"));
file_menu.add_action(Action::new("Open...").with_shortcut("Ctrl+O"));
file_menu.add_separator();
file_menu.add_menu(recent_files_menu);  // Submenu
file_menu.add_separator();
file_menu.add_action(Action::new("Quit").with_shortcut("Ctrl+Q"));

Action

Reusable command with text, icon, and shortcut.

use horizon_lattice::widget::widgets::Action;

let save_action = Action::new("Save")
    .with_shortcut("Ctrl+S")
    .with_icon(Icon::from_name("save"))
    .with_enabled(true);

save_action.triggered.connect(|| {
    println!("Save triggered!");
});

// Checkable action
let word_wrap = Action::new("Word Wrap")
    .with_checkable(true)
    .with_checked(true);

word_wrap.toggled.connect(|&checked| {
    editor.set_word_wrap(checked);
});

Toolbar Widgets

ToolBar

Application toolbar.

use horizon_lattice::widget::widgets::ToolBar;

let mut toolbar = ToolBar::new("Main");
toolbar.add_action(new_action);
toolbar.add_action(open_action);
toolbar.add_separator();
toolbar.add_widget(search_box.object_id());
toolbar.set_movable(true);
toolbar.set_floatable(true);

Status Bar

StatusBar

Window status bar.

use horizon_lattice::widget::widgets::StatusBar;

let status = StatusBar::new();
status.show_message("Ready");
status.show_message_for("Saved!", Duration::from_secs(3));

// Permanent widgets
status.add_permanent_widget(progress.object_id());
status.add_permanent_widget(position_label.object_id());

Layout Reference

A comprehensive reference of all layout types available in Horizon Lattice.

Common Concepts

ContentMargins

All layouts support content margins - the space between the layout edge and its contents.

use horizon_lattice::widget::layout::ContentMargins;

// Uniform margins on all sides
let margins = ContentMargins::uniform(16.0);

// Symmetric margins (vertical, horizontal)
let margins = ContentMargins::symmetric(8.0, 16.0);

// Individual margins (left, top, right, bottom)
let margins = ContentMargins::new(10.0, 8.0, 10.0, 12.0);

// Apply to layout
layout.set_content_margins(margins);

LayoutKind

Layouts must be wrapped in LayoutKind when setting on a container.

use horizon_lattice::widget::layout::{VBoxLayout, LayoutKind};
use horizon_lattice::widget::widgets::Container;

let mut layout = VBoxLayout::new();
// Configure layout...

let mut container = Container::new();
container.set_layout(LayoutKind::from(layout));

Widget IDs

Layouts reference widgets by their ObjectId, obtained via widget.object_id().

let button = PushButton::new("Click");
layout.add_widget(button.object_id());

Box Layouts

HBoxLayout

Arranges widgets horizontally from left to right.

use horizon_lattice::widget::layout::HBoxLayout;

let mut layout = HBoxLayout::new();
layout.set_spacing(8.0);
layout.set_content_margins(ContentMargins::uniform(10.0));

// Add widgets in order
layout.add_widget(icon.object_id());
layout.add_widget(label.object_id());
layout.add_stretch(1);  // Flexible space
layout.add_widget(button.object_id());

Methods:

  • set_spacing(f32) - Space between widgets
  • add_widget(ObjectId) - Add widget at end
  • add_stretch(i32) - Add flexible space with stretch factor
  • insert_widget(usize, ObjectId) - Insert at position
  • insert_stretch(usize, i32) - Insert stretch at position

VBoxLayout

Arranges widgets vertically from top to bottom.

use horizon_lattice::widget::layout::VBoxLayout;

let mut layout = VBoxLayout::new();
layout.set_spacing(12.0);

layout.add_widget(title.object_id());
layout.add_widget(content.object_id());
layout.add_stretch(1);  // Push buttons to bottom
layout.add_widget(buttons.object_id());

BoxLayout (Generic)

Base box layout with configurable orientation.

use horizon_lattice::widget::layout::{BoxLayout, Orientation};

let mut layout = BoxLayout::new(Orientation::Horizontal);
layout.set_spacing(8.0);

Alignment

Control item alignment within box layouts.

use horizon_lattice::widget::layout::Alignment;

// Add widget with specific alignment
layout.add_widget_with_alignment(
    widget.object_id(),
    Alignment::Center
);

Alignment Values:

  • Leading - Left/Top aligned
  • Center - Centered
  • Trailing - Right/Bottom aligned
  • Fill - Stretch to fill (default)

GridLayout

Arranges widgets in a two-dimensional grid.

use horizon_lattice::widget::layout::GridLayout;

let mut grid = GridLayout::new();
grid.set_horizontal_spacing(8.0);
grid.set_vertical_spacing(8.0);

// Add widgets at specific positions (row, column)
grid.add_widget_at(label1.object_id(), 0, 0);
grid.add_widget_at(input1.object_id(), 0, 1);
grid.add_widget_at(label2.object_id(), 1, 0);
grid.add_widget_at(input2.object_id(), 1, 1);

// Widget spanning multiple cells
grid.add_widget_spanning(
    wide_widget.object_id(),
    2,  // row
    0,  // column
    1,  // row span
    2   // column span
);

Row and Column Configuration

// Set minimum row/column sizes
grid.set_row_minimum_height(0, 30.0);
grid.set_column_minimum_width(1, 200.0);

// Set stretch factors (relative sizing)
grid.set_row_stretch(0, 1);
grid.set_row_stretch(1, 2);  // Second row gets 2x space
grid.set_column_stretch(0, 0);
grid.set_column_stretch(1, 1);

Cell Alignment

use horizon_lattice::widget::layout::CellAlignment;

grid.add_widget_at_aligned(
    widget.object_id(),
    0, 0,
    CellAlignment::new(Alignment::Center, Alignment::Center)
);

FormLayout

Two-column layout optimized for label-field pairs.

use horizon_lattice::widget::layout::FormLayout;

let mut form = FormLayout::new();

// Add label-field pairs
form.add_row(Label::new("Name:"), name_input);
form.add_row(Label::new("Email:"), email_input);
form.add_row(Label::new("Password:"), password_input);

// Field spanning full width
form.add_spanning_widget(remember_me_checkbox);

// Just a field (no label)
form.add_row_field_only(submit_button);

Form Policies

use horizon_lattice::widget::layout::{RowWrapPolicy, FieldGrowthPolicy};

// Row wrap policy: when to wrap long labels
form.set_row_wrap_policy(RowWrapPolicy::WrapLongRows);

// Field growth: how fields expand
form.set_field_growth_policy(FieldGrowthPolicy::ExpandingFieldsGrow);

RowWrapPolicy:

  • DontWrapRows - Never wrap, may clip
  • WrapLongRows - Wrap labels that don’t fit
  • WrapAllRows - Always put labels above fields

FieldGrowthPolicy:

  • FieldsStayAtSizeHint - Fields stay at preferred size
  • ExpandingFieldsGrow - Only expanding fields grow
  • AllNonFixedFieldsGrow - All non-fixed fields grow

Row Access

// Get form row by index
let row = form.row_at(0);
row.set_visible(false);  // Hide entire row

// Remove a row
form.remove_row(1);

StackLayout

Shows one widget at a time (like pages in a wizard).

use horizon_lattice::widget::layout::{StackLayout, StackSizeMode};

let mut stack = StackLayout::new();

// Add pages
let page1_id = stack.add_widget(intro_page.object_id());
let page2_id = stack.add_widget(settings_page.object_id());
let page3_id = stack.add_widget(confirm_page.object_id());

// Switch pages
stack.set_current_index(0);
stack.set_current_widget(settings_page.object_id());

// Size mode
stack.set_size_mode(StackSizeMode::CurrentWidgetSize);

StackSizeMode:

  • StackFitLargestWidget - Size to fit largest child
  • CurrentWidgetSize - Size to fit current child only

FlowLayout

Arranges widgets in rows, wrapping to new lines as needed.

use horizon_lattice::widget::layout::FlowLayout;

let mut flow = FlowLayout::new();
flow.set_horizontal_spacing(8.0);
flow.set_vertical_spacing(8.0);

// Add items - they flow and wrap automatically
for tag in tags {
    flow.add_widget(TagWidget::new(tag).object_id());
}

Use Cases:

  • Tag clouds
  • Photo galleries
  • Toolbar buttons that wrap

AnchorLayout

Position widgets relative to parent or sibling edges.

use horizon_lattice::widget::layout::{AnchorLayout, Anchor, AnchorLine, AnchorTarget};

let mut anchor = AnchorLayout::new();

// Add widgets
anchor.add_widget(sidebar.object_id());
anchor.add_widget(content.object_id());
anchor.add_widget(footer.object_id());

// Anchor sidebar to parent left edge
anchor.add_anchor(Anchor::new(
    AnchorTarget::Widget(sidebar.object_id()),
    AnchorLine::Left,
    AnchorTarget::Parent,
    AnchorLine::Left,
    10.0  // margin
));

// Anchor sidebar to parent top
anchor.add_anchor(Anchor::new(
    AnchorTarget::Widget(sidebar.object_id()),
    AnchorLine::Top,
    AnchorTarget::Parent,
    AnchorLine::Top,
    10.0
));

// Anchor content to sidebar's right edge
anchor.add_anchor(Anchor::new(
    AnchorTarget::Widget(content.object_id()),
    AnchorLine::Left,
    AnchorTarget::Widget(sidebar.object_id()),
    AnchorLine::Right,
    8.0
));

AnchorLine Values:

  • Left, Right, Top, Bottom - Edges
  • HorizontalCenter, VerticalCenter - Centers

Fill Anchors

// Make widget fill parent horizontally
anchor.fill_horizontal(widget.object_id(), 10.0);

// Make widget fill parent vertically
anchor.fill_vertical(widget.object_id(), 10.0);

// Make widget fill parent completely
anchor.fill(widget.object_id(), ContentMargins::uniform(10.0));

Layout Items

SpacerItem

Flexible or fixed space in layouts.

use horizon_lattice::widget::layout::{LayoutItem, SpacerItem, SpacerType};

// Fixed size spacer
let spacer = SpacerItem::fixed(20.0, 0.0);

// Expanding spacer (flexible)
let spacer = SpacerItem::expanding(SpacerType::Horizontal);

// Add to layout
layout.add_item(LayoutItem::Spacer(spacer));

SpacerType:

  • Horizontal - Expands horizontally
  • Vertical - Expands vertically
  • Both - Expands in both directions

Nested Layouts

Layouts can be nested for complex arrangements.

use horizon_lattice::widget::layout::{VBoxLayout, HBoxLayout, LayoutKind};

// Create button row
let mut buttons = HBoxLayout::new();
buttons.set_spacing(8.0);
buttons.add_stretch(1);
buttons.add_widget(cancel_btn.object_id());
buttons.add_widget(ok_btn.object_id());

let mut buttons_container = Container::new();
buttons_container.set_layout(LayoutKind::from(buttons));

// Main layout with nested button row
let mut main = VBoxLayout::new();
main.add_widget(content.object_id());
main.add_widget(buttons_container.object_id());

Layout Invalidation

Force layouts to recalculate.

use horizon_lattice::widget::layout::{LayoutInvalidator, InvalidationScope};

// Invalidate specific widget's layout
LayoutInvalidator::invalidate(widget.object_id());

// Invalidate with specific scope
LayoutInvalidator::invalidate_with_scope(
    widget.object_id(),
    InvalidationScope::SizeHint
);

InvalidationScope:

  • Geometry - Recalculate positions and sizes
  • SizeHint - Recalculate size hints
  • All - Full recalculation

Default Values

use horizon_lattice::widget::layout::{DEFAULT_SPACING, DEFAULT_MARGINS};

// DEFAULT_SPACING = 6.0
// DEFAULT_MARGINS = ContentMargins::uniform(9.0)

Layout Trait

For custom layouts, implement the Layout trait.

use horizon_lattice::widget::layout::{Layout, ContentMargins};
use horizon_lattice::widget::SizeHint;
use horizon_lattice::render::{Rect, Size};

pub struct MyCustomLayout {
    geometry: Rect,
    margins: ContentMargins,
    // ...
}

impl Layout for MyCustomLayout {
    fn size_hint(&self) -> SizeHint {
        // Calculate preferred size
        SizeHint::preferred(Size::new(200.0, 100.0))
    }

    fn set_geometry(&mut self, rect: Rect) {
        self.geometry = rect;
        // Position child widgets
    }

    fn geometry(&self) -> Rect {
        self.geometry
    }

    fn content_margins(&self) -> ContentMargins {
        self.margins
    }

    fn set_content_margins(&mut self, margins: ContentMargins) {
        self.margins = margins;
    }
}

Style Properties Reference

All CSS properties supported by Horizon Lattice.

Box Model

margin

Outer spacing around the widget.

margin: 10px;           /* All sides */
margin: 10px 20px;      /* Vertical, horizontal */
margin: 10px 20px 15px 25px;  /* Top, right, bottom, left */

padding

Inner spacing within the widget.

padding: 8px;
padding: 8px 16px;

border-width

Border thickness.

border-width: 1px;

border-color

Border color.

border-color: #333;
border-color: rgb(51, 51, 51);

border-style

Border line style.

border-style: solid;
border-style: none;

border-radius

Corner rounding.

border-radius: 4px;
border-radius: 4px 8px;  /* TL/BR, TR/BL */

Colors

color

Text color.

color: white;
color: #ffffff;
color: rgb(255, 255, 255);
color: rgba(255, 255, 255, 0.8);

background-color

Background fill color.

background-color: #3498db;
background-color: transparent;

Typography

font-size

Text size.

font-size: 14px;
font-size: 1.2em;

font-weight

Text weight.

font-weight: normal;
font-weight: bold;
font-weight: 500;

font-style

Text style.

font-style: normal;
font-style: italic;

font-family

Font selection.

font-family: "Helvetica Neue", sans-serif;

text-align

Horizontal text alignment.

text-align: left;
text-align: center;
text-align: right;

line-height

Line spacing multiplier.

line-height: 1.5;

Effects

opacity

Transparency (0.0 to 1.0).

opacity: 0.8;

box-shadow

Drop shadow.

box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
box-shadow: 0 4px 8px #00000033;

Sizing

width, height

Explicit dimensions.

width: 200px;
height: 100px;

min-width, min-height

Minimum dimensions.

min-width: 50px;
min-height: 24px;

max-width, max-height

Maximum dimensions.

max-width: 400px;
max-height: 300px;

Interaction

cursor

Mouse cursor style.

cursor: pointer;
cursor: default;
cursor: text;

pointer-events

Enable/disable mouse interaction.

pointer-events: auto;
pointer-events: none;

Special Values

inherit

Inherit value from parent.

color: inherit;
font-size: inherit;

initial

Reset to default value.

margin: initial;

Flexbox Properties

display

Set layout mode.

display: flex;
display: block;
display: none;

flex-direction

Direction of flex items.

flex-direction: row;
flex-direction: column;
flex-direction: row-reverse;
flex-direction: column-reverse;

justify-content

Alignment along main axis.

justify-content: flex-start;
justify-content: flex-end;
justify-content: center;
justify-content: space-between;
justify-content: space-around;

align-items

Alignment along cross axis.

align-items: flex-start;
align-items: flex-end;
align-items: center;
align-items: stretch;

flex

Flex grow, shrink, and basis.

flex: 1;
flex: 0 0 auto;
flex: 1 1 200px;

gap

Space between flex/grid items.

gap: 8px;
gap: 8px 16px;  /* row-gap column-gap */

Transitions

transition

Animate property changes.

transition: background-color 0.2s ease;
transition: all 0.3s ease-in-out;
transition: opacity 0.15s, transform 0.2s;

transition-property

Properties to animate.

transition-property: background-color;
transition-property: all;
transition-property: none;

transition-duration

Animation duration.

transition-duration: 0.2s;
transition-duration: 200ms;

transition-timing-function

Easing function.

transition-timing-function: ease;
transition-timing-function: ease-in;
transition-timing-function: ease-out;
transition-timing-function: ease-in-out;
transition-timing-function: linear;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);

Pseudo-Classes

Horizon Lattice supports these pseudo-classes for state-based styling:

/* Mouse states */
Button:hover { background-color: #eee; }
Button:pressed { background-color: #ddd; }

/* Focus states */
LineEdit:focus { border-color: #3498db; }
LineEdit:focus-visible { outline: 2px solid #3498db; }

/* Enabled/disabled */
Button:disabled { opacity: 0.5; }
Button:enabled { opacity: 1.0; }

/* Checked state (for checkboxes, radio buttons) */
CheckBox:checked { color: #27ae60; }
CheckBox:unchecked { color: #999; }
CheckBox:indeterminate { color: #666; }

/* First/last child */
ListItem:first-child { border-top: none; }
ListItem:last-child { border-bottom: none; }

/* Nth child */
TableRow:nth-child(odd) { background-color: #f9f9f9; }
TableRow:nth-child(even) { background-color: #fff; }
TableRow:nth-child(3n) { font-weight: bold; }

Widget-Specific Properties

Subcontrol styling

Some widgets have subcontrols that can be styled:

/* Scrollbar */
ScrollBar::handle { background-color: #888; }
ScrollBar::handle:hover { background-color: #555; }
ScrollBar::add-line { background-color: #ddd; }
ScrollBar::sub-line { background-color: #ddd; }

/* Tab */
TabBar::tab { padding: 8px 16px; }
TabBar::tab:selected { background-color: white; }

/* Checkbox indicator */
CheckBox::indicator { width: 16px; height: 16px; }
CheckBox::indicator:checked { background-color: #3498db; }

/* ComboBox dropdown */
ComboBox::drop-down { width: 20px; }
ComboBox::down-arrow { image: url(down-arrow.png); }

Color Functions

rgb / rgba

color: rgb(255, 128, 0);
color: rgba(255, 128, 0, 0.5);

hsl / hsla

color: hsl(200, 80%, 50%);
color: hsla(200, 80%, 50%, 0.8);

color-mix

Blend two colors.

background-color: color-mix(in srgb, blue 30%, white);

Custom Properties (Variables)

Definition

:root {
    --primary-color: #3498db;
    --spacing-unit: 8px;
    --border-radius: 4px;
}

Usage

Button {
    background-color: var(--primary-color);
    padding: var(--spacing-unit);
    border-radius: var(--border-radius);
}

Fallback values

color: var(--undefined-color, #333);

Units

UnitDescriptionExample
pxPixels (device-independent)16px
emRelative to parent font size1.5em
remRelative to root font size1rem
%Percentage of parent50%
vwViewport width percentage100vw
vhViewport height percentage50vh

Shorthand Properties

margin / padding

/* Single value: all sides */
margin: 10px;

/* Two values: vertical horizontal */
margin: 10px 20px;

/* Three values: top horizontal bottom */
margin: 10px 20px 15px;

/* Four values: top right bottom left */
margin: 10px 20px 15px 25px;

border

/* width style color */
border: 1px solid #333;

/* Individual sides */
border-top: 2px dashed red;
border-left: none;

background

/* color image position/size repeat */
background: #fff url(bg.png) center/cover no-repeat;

Platform Differences

Behavior differences across Windows, macOS, and Linux.

Window Management

Title Bar

FeatureWindowsmacOSLinux
Custom title barSupportedLimitedVaries by WM
Traffic lights positionN/ALeftN/A
Menu in title barSupportedSystem menu barSupported

Window Decorations

  • Windows: Standard Win32 decorations
  • macOS: Native NSWindow decorations
  • Linux: Depends on window manager (X11/Wayland)

Styling

System Colors

Use SystemTheme::accent_color() for the platform accent color.

PlatformAccent Color Source
WindowsWinRT UISettings
macOSNSColor.controlAccentColor
LinuxXDG Portal (if available)

Dark Mode

Use SystemTheme::color_scheme() to detect light/dark mode.

PlatformDetection Method
WindowsAppsUseLightTheme registry
macOSNSApp.effectiveAppearance
LinuxXDG Portal color-scheme

Text Rendering

Font Selection

System fonts vary by platform:

  • Windows: Segoe UI
  • macOS: SF Pro / San Francisco
  • Linux: System dependent (often DejaVu)

Font Rendering

FeatureWindowsmacOSLinux
Subpixel AAClearTypeNativeFreeType
Font hintingStrongNoneConfigurable

Input

Keyboard

FeatureWindowsmacOSLinux
Command keyCtrlCmdCtrl
Context menuApplication keyCtrl+ClickApplication key
IMETSFInput SourcesIBus/Fcitx

Touch

FeatureWindowsmacOSLinux
Touch eventsNativeNativeVia libinput
GesturesWM_GESTURENSEventLimited

File System

Path Conventions

PlatformConfig DirData Dir
Windows%APPDATA%%LOCALAPPDATA%
macOS~/Library/Application SupportSame
Linux~/.config~/.local/share

Use platform::directories() for cross-platform paths.

Known Limitations

Linux

  • High contrast detection not fully implemented
  • Some advanced clipboard formats not supported on Wayland
  • Native file dialogs depend on portal availability

macOS

  • Custom title bar colors limited
  • Some animations may differ from system style

Windows

  • DPI scaling may require manifest for older apps
  • Per-monitor DPI awareness needs explicit opt-in

Graphics Backend

Renderer Selection

PlatformPrimary BackendFallback
WindowsDirect3D 12Vulkan, Direct3D 11
macOSMetal-
LinuxVulkanOpenGL

Performance Considerations

use horizon_lattice::render::GraphicsConfig;

// Force specific backend
let config = GraphicsConfig::new()
    .with_preferred_backend(Backend::Vulkan);

Clipboard

Supported Formats

FormatWindowsmacOSLinux
TextFullFullFull
HTMLFullFullPartial
ImagesFullFullX11 only
FilesFullFullWayland limited

Async Clipboard (Wayland)

On Wayland, clipboard operations may be asynchronous:

use horizon_lattice::clipboard::Clipboard;

// Prefer async API on Wayland
Clipboard::get_text_async(|text| {
    if let Some(t) = text {
        println!("Got: {}", t);
    }
});

Drag and Drop

FeatureWindowsmacOSLinux
File dropsFullFullFull
Custom dataFullFullPartial
Drag imagesFullFullX11 only

Window Behavior

Fullscreen

// Native fullscreen (best integration)
window.set_fullscreen(FullscreenMode::Native);

// Borderless fullscreen (consistent across platforms)
window.set_fullscreen(FullscreenMode::Borderless);
ModeWindowsmacOSLinux
NativeWin32NSWindowWM dependent
BorderlessConsistentConsistentConsistent

Always on Top

window.set_always_on_top(true);

Works consistently across all platforms.

Transparency

window.set_transparent(true);
window.set_opacity(0.9);
FeatureWindowsmacOSLinux
Window opacityFullFullCompositor dependent
Transparent regionsFullFullCompositor dependent

Dialogs

Native Dialogs

DialogWindowsmacOSLinux
File Open/SaveIFileDialogNSOpenPanelPortal/GTK
Color PickerChooseColorNSColorPanelPortal/GTK
Font PickerChooseFontNSFontPanelPortal/GTK
Message BoxMessageBoxNSAlertPortal/GTK

Linux Portal Integration

On Linux, Horizon Lattice uses XDG Desktop Portal when available:

use horizon_lattice::platform::linux;

// Check if portals are available
if linux::portals_available() {
    // Native dialogs will use portals
} else {
    // Falls back to GTK dialogs
}

Keyboard Shortcuts

Modifier Key Mapping

ActionWindows/LinuxmacOS
CopyCtrl+CCmd+C
PasteCtrl+VCmd+V
CutCtrl+XCmd+X
UndoCtrl+ZCmd+Z
RedoCtrl+YCmd+Shift+Z
Select AllCtrl+ACmd+A
SaveCtrl+SCmd+S
FindCtrl+FCmd+F
Close WindowAlt+F4Cmd+W
QuitAlt+F4Cmd+Q

Horizon Lattice automatically maps shortcuts appropriately per platform.

Accessibility

Screen Readers

PlatformSupported API
WindowsUI Automation
macOSNSAccessibility
LinuxAT-SPI2

High Contrast

use horizon_lattice::platform::SystemTheme;

if SystemTheme::is_high_contrast() {
    // Adjust colors for visibility
}
PlatformDetection
WindowsSystemParametersInfo
macOSNSWorkspace
LinuxPortal (partial)

Locale and Text

Input Methods

PlatformIME Framework
WindowsTSF (Text Services Framework)
macOSInput Sources
LinuxIBus, Fcitx, XIM

Right-to-Left Text

Full RTL support on all platforms. Use TextDirection::Auto for automatic detection:

use horizon_lattice::text::TextDirection;

label.set_text_direction(TextDirection::Auto);