Added a script to regenerate the screenshots referenced in docs/layout.md directly from the
Rust snippets next to them, instead of hand-capturing them by running examples/layout.rs
interactively (which silently goes stale whenever a snippet changes).
examples/gen_doc_screenshots.rs: scansdocs/layout.mdfor fenced```rustblocks followed by areference, and generatesexamples/doc_screenshots.rs(a gitignored build artifact, not committed) containing onefn scene_N() -> Scene<Rgb565>per matched snippet plus amain()that renders each headlessly and saves the PNG.scripts/gen_doc_screenshots.sh: runs the two examples in sequence (gen_doc_screenshotsto codegen, thendoc_screenshots --features headlessto render). Usesembedded-graphics-simulator'sheadlessfeature (SimulatorDisplay+to_rgb_output_image().save_png()), so no SDL/window is needed.Cargo.toml: added a[[example]] name = "gen_doc_screenshots"entry;doc_screenshotsis deliberately not declared there since an explicit[[example]]entry requires the file to exist at manifest-parse time, which would break every othercargoinvocation before the file has been generated once.- Verified the regenerated PNGs are byte-identical to the previously hand-captured ones, and that editing a single snippet's button label and rerunning only changes that one screenshot.
src/list_view.rs: input_list now distinguishes PointerDown from PointerUp, same pattern as toggle_group.rs/button.rs.
ListStategained a privatepressed: Option<usize>field.PointerDownhit-tests which row the touch landed on (via a newrow_index_at()helper shared by both handlers) and stores it inpressed; sets focus and marks dirty, does not changeselected.PointerUpre-hit-tests the release point and only commits (updatingselected, firingOutputAction::Command) if release lands on the same row that was pressed — dragging off the row before lifting cancels it.draw_listinverts fill/text for the currently pressed row (even if not yetselected), so the touch is visible before release commits it.
Existing tests (test_list_view, test_list_view_grows_to_fill_parent_width, test_list_view_enter_with_stale_selected_no_panic) needed no changes — test_list_view drives via click_at (synthesized down+up at the same point), so pressed == Some(n) always holds. Verified with cargo test --features std (53 passed).
src/toggle_group.rs: input_toggle_group now distinguishes PointerDown from PointerUp instead of only reacting to PointerUp, mirroring button.rs's press/release pattern.
SelectOneOfStategained a privatepressed: Option<usize>field.PointerDownhit-tests which segment the touch landed on (via a newcell_index_at()helper shared by both handlers) and stores it inpressed; this only sets focus and marks the view dirty, it does not changeselected.PointerUpre-hit-tests the release point and only commits the selection (updatingselectedand returningOutputAction::Command) if the release lands on the same segment that was pressed — dragging off the widget before lifting cancels the tap, same asbutton.rs.draw_toggle_groupnow inverts a segment's fill/text while it is the pressed one (even if not yetselected), so the touch is visible before release commits it.
Existing tests (test_toggle_group, test_toggle_group_empty_no_panic), which drive the widget via click_at (a synthesized down+up at one point), needed no changes and still pass — click_at's down and up share the same point, so pressed == Some(n) always holds. Verified with cargo test --features std (53 passed).
Parameterized the entire toolkit over the pixel color type instead of hardcoding Rgb565, so it can drive displays in any embedded_graphics::pixelcolor::PixelColor mode (Rgb888, Gray8, BinaryColor, etc.), not just RGB565.
src/gfx.rs:TextStyleand theDrawingContexttrait gained aC: PixelColorparameter (TextStyle<'a, C>,DrawingContext<C>); every color-bearing method now takes&Cinstead of&Rgb565.src/lib.rs:ViewStyle,Theme,DrawEvent,LayoutEvent,GuiEvent, and theDrawFn/LayoutFn/InputFn/Callbacktype aliases all gained<C: PixelColor>.BW_THEMEstays a concreteTheme<Rgb565>(the common default); build your ownTheme<C>literal for other color types.src/view.rs:View→View<C: PixelColor>;src/scene.rs:Scene→Scene<C: PixelColor>, and every dispatch/layout/draw function (click_at,pointer_down_at,pointer_up_at,event_at_focused,pick_at,draw_scene,layout_scene, ...) gained the same parameter.src/device.rs: generalized the oldFromRgb565trait intoFromColor<Src: PixelColor>, with a blanket identity impl (impl<C> FromColor<C> for C) plus the existingBinaryColor-from-Rgb565threshold conversion.EmbeddedDrawingContext<'a, T>becameEmbeddedDrawingContext<'a, T, C = T::Color>, converting the theme's logical colorCinto the display's native colorT::ColorviaT::Color: FromColor<C>. This preserves the existing two-tier design (oneTheme<Rgb565>can still drive both a full-color display and a 1-bit e-paper display via automatic conversion, asexamples/simulator.rsdoes) while letting the logical color be anything.- All widget constructors (
make_button,make_label,make_panel,make_list_view,make_text_input,make_toggle_button,make_toggle_group,make_tabbed_panel,make_grid_panel) and layout functions became generic over<C: PixelColor>. Their bodies were untouched — they only ever touch color throughtheme.*.fill/.text, so genericity falls out for free. src/grid.rs: the one widget with a hardcoded color (Rgb565::REDfor debug gridlines) now usestheme.accented.fillinstead, removing itsembedded_graphics::pixelcolordependency entirely.src/test.rs:MockDrawingContext/make_mock_theme()were deliberately kept concrete atRgb565(not genericized) — every test constructs a scene/theme through one of these, so Rust's local type inference resolvesC = Rgb565throughout each test body for free. As a result almost no test code needed to change; only a handful of test helper functions that hadScene/View/DrawEvent/LayoutEvent/GuiEventin their own signature (not just their body) needed an explicit<Rgb565>added, since function signatures aren't inferred.examples/simulator.rs,examples/custom_view.rs,esptest/src/main.rs: updated call sites accordingly (Theme<Rgb565>on the theme consts,Scene<Rgb565>/View<Rgb565>on a few helper signatures,FromRgb565→FromColor<Rgb565>bound inrun_loop). Behavior is unchanged — verified the simulator's existing dual Rgb565/BinaryColor (e-paper) code path still type-checks against one sharedTheme<Rgb565>.
Replaced the single InputEvent::Tap(Point) event with InputEvent::PointerDown(Point) and InputEvent::PointerUp(Point), so the toolkit can distinguish a press from a release (mouse down/up on desktop, touch down/up on embedded).
src/scene.rs: addedpointer_down_at/pointer_up_at, each hit-testing at the given point and dispatching the corresponding event to the topmost view, exactly like the oldclick_at.click_atis now a convenience wrapper that callspointer_down_atthenpointer_up_atat the same point (simulates a full click) and is unchanged for existing callers/tests.src/button.rs:ButtonStategained apressed: boolfield.PointerDownsets focus +pressed = true(no action yet);PointerUpclearspressedand only firesOutputAction::Commandif the button was actually pressed first. The draw function now inverts fill/text color while pressed, so the button visibly depresses and un-depresses. Added atestsmodule covering press/release/no-op-release/click_at.src/text_input.rs,src/list_view.rs,src/toggle_group.rs: theirInputEvent::Tapmatch arms were renamed toInputEvent::PointerUp(selection/focus already only made sense on release).src/toggle_button.rs:input_toggle_buttonpreviously reacted to any event type; now explicitly filters toInputEvent::PointerUponly, otherwise it would double-toggle (once for down, once for up) sinceclick_atnow dispatches both.examples/simulator.rs:MouseButtonDownnow callspointer_down_at(previously a no-op);MouseButtonUpcallspointer_up_atinstead ofclick_at.esptest/src/main.rs: the touch poll loop had no down/up edge detection (it calledclick_atevery ~100ms poll while a finger stayed down). Added alast_touch_pointstate so a touch-down firespointer_down_atonce, and release (no touch point reported) firespointer_up_atonce at the last known point.
Added set_focus_enabled(bool) / is_focus_enabled() to Scene in src/scene.rs. When focus is disabled, set_focused is a no-op: focused stays None, no dirty rects are produced for focus transitions, no focus rings are drawn, and event_at_focused dispatches to nothing. Useful on e-paper touch screens where minimizing dirty rects is critical. Three new tests in focus_disabled_tests verify the flag prevents dirty rect expansion, that the default (enabled) behaviour is unchanged, and that the flag can be toggled at runtime.
Fixed layout_list in list_view.rs ignoring h_flex = Grow:
layout_list set the view height based on item count but never touched the width, leaving it at the default 100px regardless of the flex setting. Added a check: when h_flex == Grow, width is set to e.space.w (the space offered by the parent), matching the pattern used by all other layout functions.
Added regression test test_list_view_grows_to_fill_parent_width which places a Grow list view inside a 320px-wide layout_vbox parent and asserts the list width equals 320px. The test failed before the fix and passes after.
Fixed gap accounting bug in layout_hbox and layout_vbox:
Previously, gap was applied during the child positioning pass but was never deducted from the space offered to Grow children. With N total children and a gap G, the Grow children were collectively given G × (N−1) extra pixels, causing them to overflow the container silently.
Fix: before dividing remaining space among Grow children, subtract gap × (total_children − 1) in both layout_hbox (avail_horizontal_space) and layout_vbox (vert_leftover).
Added two regression tests (test_hbox_grow_child_accounts_for_gap, test_vbox_grow_child_accounts_for_gap) that were written first to confirm the overflow, then pass after the fix.
Minor fixes from code review issues #19–#20:
- #19:
Size::empty()ingeom.rsnow returnsSize { w: 0, h: 0 }instead of the magic sentinelSize { w: -99, h: -99 }.is_empty()treats anyw < 1 || h < 1as empty, so zero is semantically identical but not misleading. - #20:
TextInputStatecursor methods intext_input.rsnow navigate by char boundary instead of by byte.cursor_backuseschar_indices().next_back()to find the previous char's byte offset;cursor_forwarduseschars().next()andlen_utf8()to advance past the current char;insert_charadvances bykey.len_utf8(). This prevents panics on multi-byte char boundaries if non-ASCII content reaches the widget.
Performance fixes from code review issues #17–#18:
- #17:
Scene::get_children_idsnow returns&[ViewId]instead ofVec<ViewId>, eliminating the heap allocation and full clone on every call. All call sites that need to mutate the scene after iterating children (layouts, draw, tabbed_panel, grid) collect to an ownedVecwith.to_vec()first; pure read-only callers (pick, dump, filtered getter) iterate the slice directly. - #18:
draw_viewnow accepts an accumulatedoffset: Pointand skips entire view subtrees whose global bounds do not intersectscene.dirty_rect.Bounds::intersectswas added togeom.rsto support the check. Whendirty_rect.is_empty()(full-redraw path), culling is disabled and all views are drawn.draw_scenepassesPoint::zero()as the initial offset. - Added
Bounds::intersectsunit test ingeom.rs. - Added two unit tests in
scene.rs(gated onheadlessfeature):test_dirty_rect_culls_non_overlapping_viewverifies that a view outside the dirty region is not drawn;test_empty_dirty_rect_draws_all_viewsverifies that an empty dirty_rect disables culling.
API design fixes from code review issues #14–#16:
- #14:
make_label,make_header_label(label.rs) andmake_text_input(text_input.rs) now acceptname: &ViewIdinstead ofname: &'static str, matching every other widget constructor. Updated all call sites ingrid.rstests andexamples/simulator.rs. - #15:
ListState::new_withandSelectOneOfState::new_withnow returnSelfinstead ofBox<dyn Any>. Boxing moved to the call site inmake_list_viewandmake_toggle_group. Removed now-unusedcore::any::Anyimports from both files. - #16:
PanelStategains an explicitimpl Default(border_visiblecannot derive because its default istrue).SelectedStategains#[derive(Default)]. Both keep theirnew()methods as thin delegates todefault().
Idiomatic Rust cleanups from code review issues #7–#13:
- #7:
scene.rsset_focusedandevent_at_focused— replacedis_some()+unwrap()pairs withif let Some(...). - #8:
scene.rsclick_at— changedhandlers: &Vec<Callback>tohandlers: &[Callback]. - #9:
scene.rsget_children_ids_filtered— replaced.map(...).flatten()with.filter_map(...). - #10: moved
&'static str → ViewIdconversion from animpl Intoingrid.rsto a properimpl From<&'static str> for ViewIdinview.rswhere it applies unconditionally. - #11:
grid.rs— removed two empty no-opif view.h_flex == Shrink {}/if view.v_flex == Shrink {}branches; removed now-unusedShrinkimport. - #12:
layouts.rs— removed redundant.clone()calls onCopytypes (Insets,Flex,Size) inlayout_vbox,layout_hbox, andlayout_std_panel. - #13:
layouts.rslayout_vboxfold — removed misleadingreturninside the closure (returns from the closure, not the function).
Fixed correctness bugs from code review issues #3–#6:
- #3 (already fixed):
layouts.rshbox flex space correctly usesavailable_space.w— carried over from the prior "fix hbox layout bug" commit. Updated thetest_hbox_fixed_widthassertion to match correct values (child2 width 120px, child3 x=160). - #4:
scene.rsmove_view_to_parentnow also callsself.parents.insert(child, parent)soget_parent_for_viewreturns the correct parent after a move. Added assertion toparent_childtest. - #5:
scene.rsremove_parent_and_childrenis now recursive: replacesremove_view(&kid)withremove_parent_and_children(&kid)so grandchildren are fully cleaned up from all three maps. Addedremove_parent_and_children_cleans_grandchildrenregression test. - #6:
geom.rsPoint::scaledandBounds::scalednow usescale.min(i32::MAX as u32) as i32instead ofscale as i32to avoid silent wrapping on large scale values.
Fixed crash/panic risks from code review issues #1 and #2:
toggle_group.rsinput_toggle_group/draw_toggle_group: addedis_empty()early-return guard beforebounds.size.w / items.len()division to prevent divide-by-zero on an empty items list.list_view.rsTextAction::Enterpath: replaced uncheckeditems[state.selected]withitems.get(state.selected)so a staleselectedindex (e.g. after external truncation) cannot panic.- Added regression tests:
test_toggle_group_empty_no_panicandtest_list_view_enter_with_stale_selected_no_panic.
Fixed remaining crash paths in list_view.rs when the list is empty or has zero-height cells:
input_list/draw_list: guard againstcell_height <= 0(can happen when the view has no height yet) in addition to the empty-items guard added previously.TextAction::Enter: wrapped in!state.items.is_empty()check to prevent an out-of-bounds index on an empty vec.
Added inline comments to test_cliprect_nested explaining how nested parent offsets accumulate into scene coordinates and what each assertion proves (src/lib.rs).
Added test_cliprect_toggle_group_in_panel — verifies that clicking a toggle group inside a panel marks only the toggle group's global bounds dirty, not the panel or full scene (src/lib.rs). Also adds make_panel and make_toggle_group imports to the test module.
Fixed divide-by-zero panic in list_view.rs when the list has no items: input_list and draw_list now return early if state.items.is_empty().
Fixed cargo test failures caused by optional crates (test-log, embedded_graphics_simulator, env_logger) being used without feature gates:
src/layouts.rs,src/lib.rs,src/tabbed_panel.rs,src/scene.rs: Added#[cfg(any(feature = "std", feature = "headless"))]to test modules that depend ontest_logor cross-reference helpers from other feature-gated test modules.Cargo.toml: Added[[example]]entry forcustom_viewwithrequired-features = ["std"]so it is not compiled during a plaincargo test.
cargo test (no features) now runs 10 tests; cargo test --features std runs all 25.
Added examples/orientation.rs — demonstrates display orientation using an inline RotatedDisplay<D> wrapper.
embedded-graphics-transform 0.1.0 targets embedded-graphics-core 0.3 (embedded-graphics 0.7.x) and is incompatible with this project's 0.8.x (core 0.4). Instead, RotatedDisplay is implemented directly in the example using the same coordinate transforms the crate uses:
- Rotate0: identity
- Rotate90:
(phys_w-1-y, x) - Rotate180:
(phys_w-1-x, phys_h-1-y) - Rotate270:
(y, phys_h-1-x)
The wrapper implements DrawTarget and Dimensions so that draw_content receives a canvas whose bounding_box() reflects the logical (post-rotation) size, making the scene automatically adapt between landscape (0°/180°) and portrait (90°/270°).
Press R to cycle through all four rotations.
cargo run --example orientation --features std
Made EmbeddedDrawingContext generic over display color type (src/device.rs).
Added pub trait FromRgb565: PixelColor with implementations for Rgb565 (identity) and BinaryColor (black→Off, anything else→On). All drawing structs and functions (EmbeddedDrawingContext, ScaledDisplay, draw_ttf_glyphs) now use T: DrawTarget with T::Color: FromRgb565 instead of the hard-coded T: DrawTarget<Color = Rgb565>. Colors from the UI layer (always Rgb565) are converted at the draw boundary via T::Color::from_rgb565(...).
To use a BinaryColor display:
let mut display: SimulatorDisplay<BinaryColor> = SimulatorDisplay::new(size);
let mut ctx = EmbeddedDrawingContext::new(&mut display);Fixed TTF text centering in ctx.text() (src/device.rs). The text() method is used by draw_centered_text, which buttons call with bounds.center() as the position.
- Horizontal: The TTF branch was ignoring
style.halignand starting the cursor atposition.xdirectly, so all text drew from the center point rightward. NowAlign::Centeroffsets the cursor left by(total_w + first_xmin) / 2so visible glyphs are centered on the position. - Vertical: The TTF branch passed
position.ydirectly as the baseline, placing text below center. NowAlign::Centerusesfont.horizontal_line_metricsto computebaseline_y = position.y + (ascent + descent) / 2, which aligns the visual midpoint of the text with the given position (falls back tosize * 0.25offset if metrics are unavailable).
Added --scale=N command-line option to the simulator example. The scale defaults to 2 if not specified.
cargo run --example simulator --features std -- --scale=1
cargo run --example simulator --features std -- --scale=3
Fixed compile error in examples/simulator.rs: fontdue::Font::from_bytes expects impl Deref<Target=[u8]>; passing &Vec<u8> resolves to Vec<u8>, not [u8]. Fixed by passing bytes.as_slice().
Added TTF font option to the simulator example and documented font usage in README.
examples/simulator.rs: Addedget_ttf_font()helper (gated behind#[cfg(feature = "ttf")]) that loads a system font at runtime (Geneva on macOS, DejaVu Sans on Linux) usingOnceLock<Option<fontdue::Font>>. Added a TTF button to the font-size toolbar (also#[cfg(feature = "ttf")]) and a"font-ttf"command handler inhandle_events. Run withcargo run --example simulator --features std,ttf.README.md: Replaced the Themes section with updated field names (standard,panel,selected,accented). Added a Fonts subsection explainingFontKind::BitmapvsFontKind::TrueType, with std (OnceLock) and no_std (MaybeUninit) usage examples.
Added optional TrueType font support via fontdue, gated behind the ttf Cargo feature.
Cargo.toml: Addedfontdue = { version = "0.9", optional = true, default-features = false }andttf = ["fontdue"]feature. Thedefault-features = falseenables fontdue'sno_std + allocmode, matching the rest of the crate.src/font.rs(new): DefinesFontKindenum withBitmap(MonoFont<'static>)and (whenttffeature enabled)TrueType { font: &'static fontdue::Font, size: f32 }variants. ImplementsCopy,Clone, andDebug. Provides metric helpers:char_width(),char_height(),baseline(),str_width().src/lib.rs: Re-exportsFontKind.Theme.fontandTheme.bold_fontchanged fromMonoFont<'static>toFontKind. Added#[derive(Clone, Copy)]toTheme. UpdatedBW_THEMEconstant.src/gfx.rs:TextStyle.fontchanged from&'a MonoFont<'static>toFontKind(by value).draw_centered_textupdated accordingly.src/util.rs:calc_sizeandcalc_boundsupdated to takeFontKindand use metric helpers.src/device.rs:fill_textdispatches onFontKind. TTF rendering usesdraw_ttf_glyphs(alpha threshold >127). Fixed pre-existing bug where text centering metrics were hardcoded toFONT_6X10regardless of the active font.- All widget files updated to pass
FontKindby value and use.char_width()/.char_height()/.str_width()instead of.character_size.*. examples/simulator.rsandexamples/custom_view.rs: UpdatedThemeconstruction to wrap font constants withFontKind::Bitmap(...).
Developer usage: iris-ui = { features = ["ttf"] }, then construct fontdue::Font in a static and pass FontKind::TrueType { font, size } in the theme.
Added integer scale factor support to Scene and EmbeddedDrawingContext.
src/geom.rs: AddedBounds::scaled(scale)andPoint::scaled(scale)helpers.src/scene.rs: Addedscale: u32field (default 1),Scene::new_with_scale(bounds, scale)constructor, andscene.scale()getter. All layout, picking, and dirty tracking remain in logical coordinates.src/device.rs: Addedscalefield toEmbeddedDrawingContextandnew_with_scale()constructor. Geometric primitives (rect, line) multiply coordinates by scale. Text uses aScaledDisplaywrapper that turns each logical pixel into ascale×scaleblock, achieving true pixel-doubling of bitmap fonts.examples/simulator.rs: Wired scale into display size, drawing context, clip, and mouse input (divides physical coords by scale before hit-testing). Enabled scale=2 inmake_scene().
Default scale is 1, so all existing call sites (including the ESP target) are unchanged.
- Upgraded
embedded-graphics-simulatorfrom 0.7.0 to 0.8.0, which pulls insdl20.38.0. - Fixes a panic ("trying to construct an enum from an invalid value 0x207") caused by newer SDL2 system library (2.28+) emitting event types that
sdl20.37.0 did not recognize.