Skip to content

Components

Components are structs that implement Component. They hold persistent state and return an Element tree from render.

See Ctx for the full Ctx API used inside create and render, and Reactivity for signals, stores, memos, effects, and contexts.

use lurq::{
app::{component::Component, ctx::Ctx},
core::Signal,
layout::{Alignment, text_style::{FontWeight, TextStyle}},
node::{Element, color::Color},
};
struct Counter {
count: Signal<i32>,
}
impl Component for Counter {
type Props = ();
fn create(ctx: &mut Ctx) -> Self {
Self { count: ctx.signal(0) }
}
fn render(&self, _ctx: &mut Ctx) -> impl Into<Element> {
let dec = self.count.clone();
let inc = self.count.clone();
let value = self.count.get();
lurq::components::Row::new()
.spacing(12.0)
.align_items(Alignment::Center)
.child(
lurq::components::Rect::new(36.0, 36.0)
.background("#ef4444")
.rounded(6.0)
.on_click(move |_| dec.update(|n| *n -= 1)),
)
.child(lurq::components::Text::styled(
&format!("{value}"),
TextStyle {
font_size: 24.0,
weight: FontWeight::Bold,
color: Color::from_hex("#1e293b"),
..TextStyle::default()
},
))
.child(
lurq::components::Rect::new(36.0, 36.0)
.background("#22c55e")
.rounded(6.0)
.on_click(move |_| inc.update(|n| *n += 1)),
)
}
}
pub trait Component: Send + Sync + 'static {
type Props: Send + PartialEq + 'static;
fn create(ctx: &mut Ctx) -> Self;
fn render(&self, ctx: &mut Ctx) -> impl Into<Element>;
fn after_layout(&self) {}
fn on_mounted(&self) {}
fn on_unmounted(&self) {}
}
MethodCalledPurpose
createOnce, when the component is mountedInitialize persistent state
renderOn mount and when the component is dirtyReturn the current element tree
after_layoutAfter a committed layout, with element refs updatedRead measurements or initialize Canvas drawing
on_mountedAfter first renderSetup hooks that need a mounted component
on_unmountedBefore the component is removedCleanup

Components receive props through the Props associated type. The current props are stored on the component context and can be read with ctx.props::<Self::Props>().

struct Greeting;
#[derive(Clone, PartialEq, lurq::DevtoolsInspectable)]
struct GreetingProps {
name: String,
}
impl Component for Greeting {
type Props = GreetingProps;
fn create(_ctx: &mut Ctx) -> Self {
Self
}
fn render(&self, ctx: &mut Ctx) -> impl Into<Element> {
let props = ctx.props::<Self::Props>();
lurq::components::Text::new(&format!("Hello, {}!", props.name))
}
}

Use () for components with no props. Reused components rerender when their props compare unequal, so custom props must implement PartialEq.

Read changing props in render; copying them into the struct only in create would keep the initial value after a parent updates them.

When the devtools feature is enabled, props must also implement DevtoolsInspectable.

#[derive(Clone, PartialEq, lurq::DevtoolsInspectable)]
struct GreetingProps {
name: String,
}

Mount child components inside render with Ctx.

fn render(&self, ctx: &mut Ctx) -> impl Into<Element> {
lurq::components::Column::new()
.spacing(16.0)
.child(ctx.mount::<Header>(HeaderProps { title: "App" }))
.child(ctx.mount::<Counter>(()))
.child(ctx.mount::<Footer>(()))
}

ctx.mount::<C>(props) matches children by position and component type. If the same component type stays at the same slot, its instance is reused. The child rerenders when its props change or its own context is dirty.

ctx.mount_keyed::<C>(key, props) matches children by key and type. Use keyed mounts for lists that can reorder.

lurq::components::Column::new().with_children(
self.items.get().iter().map(|item| {
ctx.mount_keyed::<TodoItem>(&item.id, item.clone())
}),
)

Use mount_with or mount_keyed_with when a component needs children supplied by its parent.

ctx.mount_with::<Panel>(PanelProps { title: "Tools" }, vec![
lurq::components::Text::new("content").into(),
])

WindowChrome is a strict wrapper for custom desktop chrome. It disables native decorations when custom chrome is active, renders the title bar and content area, owns the draggable title-bar behavior, adds resize hit zones, and provides standard window controls.

use lurq::{
components::{ChromeTitleBar, Text, WindowChrome, WindowControls},
node::color::Color,
};
fn render(&self, ctx: &mut Ctx) -> impl Into<Element> {
WindowChrome::new()
.title_bar(
ChromeTitleBar::new()
.leading(Text::new("My App").padding_horizontal(12.0))
.trailing(Text::new("ready").color(Color::from_hex("#94a3b8")))
.controls(WindowControls::new().on_close(|| {
// Optional app cleanup before the window closes.
})),
)
.content(app_content(ctx))
.overlay(fullscreen_modal_layer(ctx))
.mount(ctx)
}

WindowControls::on_close is a cleanup callback before unconditional close. For a confirmation dialog, use a custom button calling window.request_close() as shown in Window lifecycle.

Apps provide title-bar slots and content. They should not manually call window.start_drag() or render resize handles for normal custom chrome. WindowChrome handles title-bar drag, double-click maximize, resize edge and corner hit zones, control-button event propagation, and platform drag/resize fallbacks in the active shell.

Use WindowChrome::overlay(...) for fullscreen modals or app-level overlay layers that should cover content but stay below chrome resize hit zones. WindowChrome renders content first, then overlays, then chrome borders and resize handles, so modal layers do not make an undecorated window impossible to resize.

ChromeBorderPolicy decides the frame outline. PlatformDefault paints a 1px #252a32 line on Windows and none elsewhere; Visible { size, color } paints the given line; Hidden paints none. Custom chrome also sets WindowBorderColor::None on the window, because Windows 11 DWM otherwise draws its own 1px border around undecorated windows, which would show even with Hidden. The title bar’s bottom line is separate: ChromeTitleBar::new() draws a 1px #252a32 bottom border, and border_bottom(None) removes it.

Use WindowChromeProps for policy-level changes:

use lurq::{
components::{ChromeBorderPolicy, ResizeHandlePlacement, ResizeHandlePolicy, WindowChromeMode, WindowChromeProps},
node::color::Color,
};
let props = WindowChromeProps::new()
.mode(WindowChromeMode::AlwaysCustom)
.resize_handles(ResizeHandlePolicy::Enabled { size: 6.0 })
.resize_placement(ResizeHandlePlacement::Overlay)
.border(ChromeBorderPolicy::Visible {
size: 1.0,
color: Color::from_hex("#252a32").into(),
});

Resize hit zones are invisible strips (3 px by default) along each edge and corner. With the default ResizeHandlePlacement::Overlay, they cover the outermost pixels of the content, and the content fills the window below the title bar: a 1440 px wide window gives 1440 px of content. A press in those pixels starts a resize instead of reaching the content. ResizeHandlePlacement::Inset reserves a gutter instead: content and overlays are inset by the handle size on the left, right, and bottom, and the gutter is painted with WindowChrome::frame_background (or the content background). Maximized and fullscreen windows have no resize handles or gutter.

WindowChrome::metrics() returns WindowChromeMetrics, which can be used for modal or overlay coordinate adjustment when a component needs to reason about the custom chrome content area. Metrics include whether custom chrome is enabled, the title-bar height, resize handle size and placement, and border size. content_x, content_width, and content_height subtract the resize gutter only with ResizeHandlePlacement::Inset.

By default, Windows-style controls are 46 px wide text glyphs with a red close hover. Each control’s content and colors can be replaced. Colors accept Color, hex strings, and PaletteColor roles, including PaletteColor::Extra:

use lurq::{
app::theme::{PaletteColor, TypographyStyle},
components::{WindowControlContent, WindowControlKind, WindowControls},
};
// "icon" is an app typography role that selects the icon font at 14 px;
// the ICON_* constants are that font's code points.
let icon = |glyph: &str| WindowControlContent::glyph(glyph, TypographyStyle::extra("icon"));
let controls = WindowControls::new()
.button_size(46.0, 32.0)
.content(WindowControlKind::Minimize, icon(ICON_MINIMIZE))
.content(WindowControlKind::Maximize, icon(ICON_MAXIMIZE))
.content(WindowControlKind::Restore, icon(ICON_RESTORE))
.content(WindowControlKind::Close, icon(ICON_CLOSE))
.foreground(PaletteColor::TextSecondary)
.hover_background(PaletteColor::extra("chrome_hover"))
.active_background(PaletteColor::extra("chrome_active"));

hover_background and active_background apply to every control, close included. To style one control, for example to keep a red close hover, use control_colors(kind, WindowControlColors { .. }). WindowControlContent::element(|foreground| ...) builds any other content, such as an SVG icon, and receives the configured foreground color. Hover and active change the background only; the foreground stays the same. Maximize content is shown while the window is restored and Restore content while it is maximized.

macOS traffic lights use TrafficLightColors. The defaults are the macOS 11+ colors sampled from the system controls, because Apple does not publish them: close #FF5F57, minimize #FEBC2E, zoom #28C840. Override them with WindowControls::traffic_lights(TrafficLightColors { .. }).

DragContainer, Draggable, and DropZone are real components. Use their mount helpers for the explicit one-child API.

Draggable and DropZone are blank behavior wrappers. Each requires exactly one slot child and leaves layout, sizing, and initial positioning to that child.

DragContainer requires exactly one slot child as the drag surface. By default, DragContainerProps::new() bounds descendant draggables to that surface.

use lurq::components::{DragContainer, DragContainerProps, Draggable, DraggableProps, Rect, Stack};
fn render(&self, ctx: &mut Ctx) -> impl Into<Element> {
let card = Draggable::mount(
ctx,
DraggableProps::new().on_drag_move(|event| {
println!("drag delta: {}, {}", event.delta_x, event.delta_y);
}),
Rect::new(64.0, 64.0)
.background("#3b82f6")
.absolute_position(24.0, 24.0),
);
DragContainer::mount(
ctx,
DragContainerProps::new(),
Stack::new()
.size(360.0, 220.0)
.child(card),
)
}

Use DragContainerProps::new().bounds(DragBounds::None) for an unbounded drag surface.

DropZone marks its single child as a drop target. Visual styling is supplied by that child.

use lurq::components::{DropZone, DropZoneProps, Rect};
fn render(&self, ctx: &mut Ctx) -> impl Into<Element> {
DropZone::mount(
ctx,
DropZoneProps::new().on_drop(|event| {
println!("dropped from {:?} onto {:?}", event.source_id, event.target_id);
}),
Rect::new(140.0, 80.0)
.background("#22c55e33")
.border_inside(1.0, lurq::node::color::Color::from_hex("#22c55e")),
)
}

Use DraggableProps::on_drag_start, on_drag_move, and on_drag_end for high-level draggable callbacks. Low-level node handlers with the same names remain available for custom behavior. The runtime keeps the active drag captured across rerenders and dispatches on_drop to the hit DropZone on release.

let count = ctx.signal(0);
count.get(); // tracked read
count.get_untracked(); // untracked read
count.set(42); // replace
count.update( | n| * n += 1); // mutate in place
count.with( | n| format!("{n}")); // tracked borrow

Writing to a signal marks the owning component dirty. Runtime rebuilds dirty component output before layout, rendering, event dispatch, and by-ID/by-class element lookup.

let count = ctx.signal(0);
let doubled = ctx.memo({
let count = count.clone();
move | | count.get() * 2
});
let value = doubled.get();

A memo tracks signals read during computation and updates dependents only when its value changes.

let handle = ctx.create_ref::<Option<u64> > (None);
handle.set(Some(123));
let current = handle.get();

Refs persist across renders but are not reactive.

Create signals, stores, memos, refs, effects, and watchers in create and retain their handles on the component. Calling these constructors repeatedly during render creates new state or subscriptions. Render-scoped APIs such as ctx.future, ctx.stream, ctx.future_action, and ctx.query retain their own call slots.

Use stores and lenses for structured reactive state.

let user = ctx.store(User { name: "Ada".into(), age: 36 });
let name = user.lens(
| u| u.name.clone(),
| u, name| u.name = name,
);
name.set("Grace".into());
let count = ctx.signal(0);
ctx.on_effect({
let count = count.clone();
move | | println ! ("count = {}", count.get())
});

Effects run immediately and rerun when any tracked signal read inside the effect changes.

ctx.watch( & count, | value| {
println ! ("count changed to {value}");
});

Watchers run when the watched signal changes.

The high-level cycle is:

  1. Component reads reactive state during render.
  2. A signal/store/memo update marks the component dirty.
  3. Runtime detects dirty components before work that needs a fresh tree.
  4. Dirty component subtrees are rendered again.
  5. Layout/render/event lookup use the updated internal tree.

Parent components do not force child components to recreate if the child slot still matches.

impl Component for MyComponent {
type Props = ();
fn create(_ctx: &mut Ctx) -> Self {
Self
}
fn render(&self, _ctx: &mut Ctx) -> impl Into<Element> {
lurq::components::Text::new("mounted")
}
fn on_mounted(&self) {
println!("mounted");
}
fn on_unmounted(&self) {
println!("unmounted");
}
}
ctx.batch(| | {
signal_a.set(1);
signal_b.set(2);
signal_c.set(3);
});

Batching coalesces context dirty propagation until the batch ends.