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:
| Feature | Qt | Horizon Lattice |
|---|---|---|
| Code generation | External MOC tool | Rust proc-macros |
| Signal type safety | Runtime | Compile-time |
| Memory management | Manual + parent-child | Rust ownership |
| License | LGPL/Commercial | MIT/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
- API Documentation: docs.rs/horizon-lattice
- GitHub: github.com/horizonanalytic/lattice
- Issues: Report bugs or request features on GitHub
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"] }
| Feature | Description |
|---|---|
multimedia | Audio/video playback support |
networking | HTTP client, WebSocket, TCP/UDP |
accessibility | Screen 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 textPushButton- Clickable buttonLineEdit- Single-line text inputContainerWidget- 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 arrangementGridLayout- Grid arrangementFormLayout- Label/field pairsFlowLayout- 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
- Creation:
Widget::new()creates the widget - Configuration: Set properties, connect signals
- Layout: Widget is added to a layout or parent
- Showing:
show()makes it visible - Running: Widget responds to events and repaints
- 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:
- Widgets Guide - Deep dive into the widget system
- Layouts Guide - Master layout management
- Signals Guide - Advanced signal patterns
- Styling Guide - CSS-like styling in depth
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
Widgettrait with lifecycle methods WidgetBasefor common functionality- Event dispatch and propagation
- Focus management
- Coordinate mapping
Rendering
The rendering system uses wgpu for GPU-accelerated 2D graphics:
- Immediate-mode
Renderertrait - 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
- See the Widget Guide for how rendering integrates with widgets
- See the Styling Guide for CSS-like styling of widgets
- Check the API Documentation for complete details
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
new()- Create widget with WidgetBase- Configure properties and connect signals
- Add to parent/layout
show()is called (inherited from parent)paint()called when visibleevent()called for input- 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:
- Measure pass: Query each child’s
size_hint()and size policy - 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:
- Layout trait: Defines 21 methods for item management, size calculation, geometry, and invalidation
- LayoutBase: A helper struct that provides common functionality (item storage, margins, spacing, caching)
- LayoutItem: Enum wrapping widgets, spacers, or nested layouts
- 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
- Always use LayoutBase - It handles caching, invalidation, and common operations
- Mark layout valid after calculation - Call
self.base.mark_valid()at the end ofcalculate() - Skip hidden items - Use
is_item_visible()to skip hidden widgets - Cache size hints - Use
set_cached_size_hint()for performance - Handle empty layouts - Return early if
item_count() == 0 - Respect size policies - Use
get_item_size_policy()to determine if items can grow/shrink - Account for margins - Use
content_rect()to get the area inside margins - 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
- Use appropriate layouts - VBox/HBox for linear arrangements, Grid for tables, Form for input forms
- Set size policies - Help layouts make better decisions about space distribution
- Use stretch factors - Control how extra space is distributed between widgets
- Nest layouts - Combine simple layouts for complex UIs rather than using one complex layout
- Set minimum sizes - Prevent layouts from shrinking widgets too small
- 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
- Use class selectors for reusable styles across widget types
- Use type selectors for widget-specific default styles
- Use ID selectors sparingly - they have high specificity and are harder to override
- Keep specificity low - makes styles easier to maintain and override
- Use combinators to scope styles without increasing specificity too much
- Leverage themes for consistent colors and spacing across your application
- Use pseudo-classes for interactive states instead of JavaScript-style state changes
Supported Properties
Box Model
margin,padding- Edge spacingborder-width,border-color,border-style- Bordersborder-radius- Rounded corners
Colors
color- Text colorbackground-color- Background fill
Typography
font-size,font-weight,font-stylefont-family- Font name or generictext-align- left, center, rightline-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
| Type | Behavior | Use Case |
|---|---|---|
Auto | Direct if same thread, Queued otherwise | Most situations (default) |
Direct | Immediate, synchronous call | Same-thread, performance critical |
Queued | Posted to event loop | Cross-thread communication |
BlockingQueued | Queued but blocks until complete | Synchronization 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
- Keep slots short - Long operations should spawn background tasks
- Avoid blocking - Never block the main thread in a slot
- Use scoped connections - When the receiver has a shorter lifetime than the signal
- Don’t recurse - Emitting the same signal from its handler can cause infinite loops
- Use Direct for performance - When you know both sides are on the same thread
- 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
- Never block the main thread - Keep UI responsive
- Minimize cross-thread communication - Batch updates when possible
- Use signals for thread communication - They handle marshalling automatically
- Prefer async for I/O - Don’t waste threads waiting on network/disk
- Check cancellation tokens - Enable graceful shutdown of long-running tasks
- 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
- Button Clicks - Add interactivity with buttons and signals
- Forms and Validation - Build input forms with layouts
- Basic Concepts - Learn about the widget system in depth
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
clickedsignal - 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
- Clone before
move: Clonelabelandcountbefore using in the closure moveclosure: Takes ownership of cloned values- Thread-safe state: Use
AtomicU32(orMutexfor 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
- Forms and Validation - Build input forms with multiple widgets
- Signals Guide - Deep dive into the signal system
- Layouts Guide - Learn about layout management
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
- Completed the Button Clicks tutorial
- Understanding of layouts from Layouts Guide
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
| Character | Description |
|---|---|
9 | Digit required (0-9) |
0 | Digit optional |
A | Letter required (a-z, A-Z) |
a | Letter optional |
N | Alphanumeric required |
n | Alphanumeric optional |
X | Any character required |
x | Any 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
- Use FormLayout for forms - Automatically handles label alignment
- Add placeholders - Help users understand expected input
- Validate early - Use validators to prevent invalid data entry
- Provide feedback - Connect to
input_rejectedto show validation errors - Mark required fields - Use asterisks or other visual indicators
- Group related fields - Use nested layouts or separators
- Default sensible values - Pre-fill spinboxes and comboboxes
- Use appropriate widgets - SpinBox for numbers, ComboBox for fixed choices
Next Steps
- Lists and Models - Work with list views and data models
- Custom Widgets - Create your own widgets
- Widgets Guide - Deep dive into the widget system
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:
| Role | Purpose |
|---|---|
Display | Main text to show |
Decoration | Icon or image |
ToolTip | Hover tooltip text |
Edit | Value for editing |
CheckState | Checkbox state |
BackgroundColor | Background color |
ForegroundColor | Text color |
Font | Custom 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
- Use ListWidget for simple cases - When you don’t need complex data binding
- Use ListView + ListModel for structured data - Better separation of concerns
- Implement ListItem for custom types - Clean data display logic
- Handle selection appropriately - Use the right SelectionMode for your use case
- Remove from highest to lowest index - Prevents index shifting issues
- Use Arc<Mutex<>> for shared model access - Thread-safe model updates
Next Steps
- Custom Widgets - Create your own widgets
- Theming - Style your lists
- Architecture Guide - Understand the Model/View pattern in depth
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:
- Object - Provides unique identification via
object_id() - 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()andctx.height()provide dimensions directly
PaintContext Methods
| Method | Purpose |
|---|---|
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
| Event | When Triggered |
|---|---|
MousePress | Mouse button pressed |
MouseRelease | Mouse button released |
MouseMove | Mouse moved over widget |
MouseDoubleClick | Double-click detected |
Enter | Mouse enters widget bounds |
Leave | Mouse leaves widget bounds |
Wheel | Scroll wheel moved |
KeyPress | Key pressed while focused |
KeyRelease | Key released while focused |
FocusIn | Widget gained focus |
FocusOut | Widget lost focus |
Resize | Widget size changed |
Move | Widget 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
| Policy | Tab Focus | Click Focus |
|---|---|---|
NoFocus | No | No |
TabFocus | Yes | No |
ClickFocus | No | Yes |
StrongFocus | Yes | Yes |
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
- Theming - Style your custom widgets consistently
- Widget Guide - Deep dive into the widget system
- Signals Guide - Advanced signal/slot patterns
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
- Completed the Custom Widgets tutorial
- Understanding of the Widget system
- Familiarity with the Styling Guide
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
| Category | Colors | Purpose |
|---|---|---|
| Primary | primary, primary_light, primary_dark, on_primary | Brand/accent colors |
| Secondary | secondary, secondary_light, secondary_dark, on_secondary | Complementary accent |
| Background | background, surface, surface_variant | Container backgrounds |
| Text | text_primary, text_secondary, text_disabled | Text colors |
| Semantic | error, warning, success, info | Status indicators |
| Borders | border, border_light, divider | Lines 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
- File Operations - Save and load theme preferences
- Styling Guide - Deep dive into the style system
- Architecture Guide - How theming integrates with the framework
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::fsmodule
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
- Examples: Text Editor - Full-featured editor example
- Examples: File Browser - Directory navigation example
- Architecture Guide - Understanding the file system integration
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
| Feature | Description |
|---|---|
| GridLayout | 4-column button grid with cell spanning |
| State Management | Arc/Mutex for shared calculator state |
| Signal Connections | Button clicks update display |
| Button Variants | Visual distinction between button types |
| Inline Styling | Custom display label styling |
| Builder Pattern | Fluent widget configuration |
Exercises
- Add keyboard support: Handle number keys, operators, Enter for equals, Escape for clear
- Add memory functions: M+, M-, MR, MC buttons
- Add scientific functions: sin, cos, tan, sqrt, power
- Add history: Show previous calculations
- Add parentheses: Support expression grouping
Related Examples
- Settings Dialog - More complex layout patterns
- Text Editor - Keyboard input handling
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
| Feature | Description |
|---|---|
| MainWindow | Window with menu bar, central widget, status bar |
| MenuBar/Menu | Hierarchical menu structure with separators |
| Action | Commands with keyboard shortcuts |
| TextEdit | Multi-line text editing with undo/redo |
| FileDialog | Native file dialogs |
| State Tracking | Modified flag and dynamic window title |
Exercises
- Add Find/Replace: Implement search with Ctrl+F
- Add recent files: Show recently opened files in menu
- Add line numbers: Display line numbers in margin
- Add syntax highlighting: Use PlainTextEdit with highlighter
- Add multiple tabs: Support multiple documents
Related Examples
- File Browser - File navigation
- Settings Dialog - Preferences
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(¤t_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
| Feature | Description |
|---|---|
| TreeView | Hierarchical directory tree |
| ListView | File list with model |
| Splitter | Resizable split panes |
| ListModel | Dynamic file list model |
| File operations | Reading directories |
| Navigation | Up, Home, address bar |
Exercises
- Add file icons: Show different icons for file types
- Add context menu: Right-click options (Open, Delete, Rename)
- Add file details: Show columns for size, date, type
- Add search: Filter files by name
- Add bookmarks: Quick access sidebar
Related Examples
- Text Editor - File opening
- Image Viewer - Image browsing
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
| Feature | Description |
|---|---|
| ImageWidget | Image display with scaling |
| ScrollArea | Pan large images |
| Slider | Zoom control |
| ListView | Thumbnail strip |
| Splitter | Resizable panels |
| FileDialog | Multi-file selection |
Exercises
- Add keyboard navigation: Arrow keys for prev/next
- Add mouse wheel zoom: Zoom centered on cursor
- Add drag-to-pan: Click and drag to pan
- Add slideshow mode: Auto-advance with timer
- Add image info: Show dimensions, file size, date
Related Examples
- File Browser - File navigation
- Text Editor - File operations
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
| Feature | Description |
|---|---|
| TabWidget | Organize settings into tabs |
| FormLayout | Label-field arrangements |
| GroupBox | Titled setting groups |
| Dialog | Modal dialog with OK/Cancel |
| Various inputs | CheckBox, ComboBox, SpinBox, etc. |
| Settings | Persistent preferences |
Exercises
- Add validation: Validate settings before saving
- Add import/export: Import/export settings to file
- Add search: Search for settings by name
- Add keyboard shortcuts tab: Configure shortcuts
- Add preview: Live preview of appearance changes
Related Examples
- Text Editor - Using settings
- Theming Tutorial - Theme settings
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
| Feature | Description |
|---|---|
| ThreadPool | Background HTTP requests |
| CancellationToken | Cancel in-flight requests |
| ComboBox | HTTP method selection |
| TextEdit | Request body and response display |
| ProgressBar | Request progress indication |
| TreeView | Request history |
| Splitter | Resizable panels |
| TabWidget | Body/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
- Add request persistence: Save/load request collections
- Add response formatting: Pretty-print JSON/XML
- Add authentication presets: OAuth, Basic Auth, API Key
- Add environment variables: Variable substitution in URLs
- Add response testing: Assertions on response data
Related Examples
- Text Editor - File operations
- Settings Dialog - Configuration
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 textalignment- Text alignment (Left, Center, Right)word_wrap- Enable word wrappingselectable- Allow text selectionelide_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 buttonSecondary- Default button styleOutlined- Border-only buttonText- Text-only buttonDanger- Destructive action button
Signals:
clicked- Emitted when button is clickedpressed- Emitted when button is pressed downreleased- 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 labelchecked- Current checked statetristate- 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 iconTextOnly- Show only textTextBesideIcon- Text next to iconTextUnderIcon- 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 enteredPassword- Display bullets/asterisksNoEcho- Display nothingPasswordEchoOnEdit- 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 changedcursor_position_changed- Cursor movedselection_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 sizeFit- Scale to fit, preserve aspect ratioFill- Scale to fill, may cropStretch- Stretch to fill, ignore aspect ratioTile- 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);
}
Menu Widgets
MenuBar
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);
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 widgetsadd_widget(ObjectId)- Add widget at endadd_stretch(i32)- Add flexible space with stretch factorinsert_widget(usize, ObjectId)- Insert at positioninsert_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 alignedCenter- CenteredTrailing- Right/Bottom alignedFill- 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 clipWrapLongRows- Wrap labels that don’t fitWrapAllRows- Always put labels above fields
FieldGrowthPolicy:
FieldsStayAtSizeHint- Fields stay at preferred sizeExpandingFieldsGrow- Only expanding fields growAllNonFixedFieldsGrow- 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 childCurrentWidgetSize- 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- EdgesHorizontalCenter,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 horizontallyVertical- Expands verticallyBoth- 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 sizesSizeHint- Recalculate size hintsAll- 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
| Unit | Description | Example |
|---|---|---|
px | Pixels (device-independent) | 16px |
em | Relative to parent font size | 1.5em |
rem | Relative to root font size | 1rem |
% | Percentage of parent | 50% |
vw | Viewport width percentage | 100vw |
vh | Viewport height percentage | 50vh |
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
| Feature | Windows | macOS | Linux |
|---|---|---|---|
| Custom title bar | Supported | Limited | Varies by WM |
| Traffic lights position | N/A | Left | N/A |
| Menu in title bar | Supported | System menu bar | Supported |
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.
| Platform | Accent Color Source |
|---|---|
| Windows | WinRT UISettings |
| macOS | NSColor.controlAccentColor |
| Linux | XDG Portal (if available) |
Dark Mode
Use SystemTheme::color_scheme() to detect light/dark mode.
| Platform | Detection Method |
|---|---|
| Windows | AppsUseLightTheme registry |
| macOS | NSApp.effectiveAppearance |
| Linux | XDG 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
| Feature | Windows | macOS | Linux |
|---|---|---|---|
| Subpixel AA | ClearType | Native | FreeType |
| Font hinting | Strong | None | Configurable |
Input
Keyboard
| Feature | Windows | macOS | Linux |
|---|---|---|---|
| Command key | Ctrl | Cmd | Ctrl |
| Context menu | Application key | Ctrl+Click | Application key |
| IME | TSF | Input Sources | IBus/Fcitx |
Touch
| Feature | Windows | macOS | Linux |
|---|---|---|---|
| Touch events | Native | Native | Via libinput |
| Gestures | WM_GESTURE | NSEvent | Limited |
File System
Path Conventions
| Platform | Config Dir | Data Dir |
|---|---|---|
| Windows | %APPDATA% | %LOCALAPPDATA% |
| macOS | ~/Library/Application Support | Same |
| 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
| Platform | Primary Backend | Fallback |
|---|---|---|
| Windows | Direct3D 12 | Vulkan, Direct3D 11 |
| macOS | Metal | - |
| Linux | Vulkan | OpenGL |
Performance Considerations
use horizon_lattice::render::GraphicsConfig;
// Force specific backend
let config = GraphicsConfig::new()
.with_preferred_backend(Backend::Vulkan);
Clipboard
Supported Formats
| Format | Windows | macOS | Linux |
|---|---|---|---|
| Text | Full | Full | Full |
| HTML | Full | Full | Partial |
| Images | Full | Full | X11 only |
| Files | Full | Full | Wayland 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
| Feature | Windows | macOS | Linux |
|---|---|---|---|
| File drops | Full | Full | Full |
| Custom data | Full | Full | Partial |
| Drag images | Full | Full | X11 only |
Window Behavior
Fullscreen
// Native fullscreen (best integration)
window.set_fullscreen(FullscreenMode::Native);
// Borderless fullscreen (consistent across platforms)
window.set_fullscreen(FullscreenMode::Borderless);
| Mode | Windows | macOS | Linux |
|---|---|---|---|
| Native | Win32 | NSWindow | WM dependent |
| Borderless | Consistent | Consistent | Consistent |
Always on Top
window.set_always_on_top(true);
Works consistently across all platforms.
Transparency
window.set_transparent(true);
window.set_opacity(0.9);
| Feature | Windows | macOS | Linux |
|---|---|---|---|
| Window opacity | Full | Full | Compositor dependent |
| Transparent regions | Full | Full | Compositor dependent |
Dialogs
Native Dialogs
| Dialog | Windows | macOS | Linux |
|---|---|---|---|
| File Open/Save | IFileDialog | NSOpenPanel | Portal/GTK |
| Color Picker | ChooseColor | NSColorPanel | Portal/GTK |
| Font Picker | ChooseFont | NSFontPanel | Portal/GTK |
| Message Box | MessageBox | NSAlert | Portal/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
| Action | Windows/Linux | macOS |
|---|---|---|
| Copy | Ctrl+C | Cmd+C |
| Paste | Ctrl+V | Cmd+V |
| Cut | Ctrl+X | Cmd+X |
| Undo | Ctrl+Z | Cmd+Z |
| Redo | Ctrl+Y | Cmd+Shift+Z |
| Select All | Ctrl+A | Cmd+A |
| Save | Ctrl+S | Cmd+S |
| Find | Ctrl+F | Cmd+F |
| Close Window | Alt+F4 | Cmd+W |
| Quit | Alt+F4 | Cmd+Q |
Horizon Lattice automatically maps shortcuts appropriately per platform.
Accessibility
Screen Readers
| Platform | Supported API |
|---|---|
| Windows | UI Automation |
| macOS | NSAccessibility |
| Linux | AT-SPI2 |
High Contrast
use horizon_lattice::platform::SystemTheme;
if SystemTheme::is_high_contrast() {
// Adjust colors for visibility
}
| Platform | Detection |
|---|---|
| Windows | SystemParametersInfo |
| macOS | NSWorkspace |
| Linux | Portal (partial) |
Locale and Text
Input Methods
| Platform | IME Framework |
|---|---|
| Windows | TSF (Text Services Framework) |
| macOS | Input Sources |
| Linux | IBus, 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);