Your First Contribution
A walk through what a real HelixScreen contribution looks like, using code already in the repo. No invented “Hello World” — you’ll learn by reading a feature that shipped and understanding why each piece is there.
What contributions actually look like
Section titled “What contributions actually look like”HelixScreen has a fixed set of top-level navigation panels — home, print-select, controls, filament, settings, advanced. These don’t get added to. If you think you need a new top-level panel, step back — you almost certainly want an overlay, a modal, or a settings subpage instead.
Realistic contribution shapes, ranked by scope:
| Contribution | Typical scope | Examples |
|---|---|---|
| Theme / design token tweak | Pure XML + token edits | New dark variant, spacing adjustment |
| Translation | YAML + regenerated artifacts | Add a new language, fix a string |
| Layout fix at a breakpoint | Pure XML | 480x320 clipping, portrait wrapping |
| Printer database entry | JSON only | Add support for a new printer model |
| Settings overlay | XML + C++ class inheriting OverlayBase | Retraction Settings, Barcode Scanner |
| Feature modal | XML + Modal subclass | Plugin Install, Bed Mesh Rename |
| Component widget | XML component + optional C++ custom widget | ui_card, filament_sensor_indicator |
| Full feature subsystem | Multiple XML components + C++ panel + state backend | AMS, Filament panel, Macros panel |
This doc focuses on the settings overlay tier (the most common real contribution shape) with a pattern tour of the full-feature tier at the end.
Part 1: The walkthrough — Retraction Settings Overlay
Section titled “Part 1: The walkthrough — Retraction Settings Overlay”The retraction settings overlay is about as small as a real contribution gets: five sliders, a toggle, and a G-code send. It lives in three files:
| File | Lines | Purpose |
|---|---|---|
ui_xml/retraction_settings_overlay.xml | 174 | Layout, styling, bindings |
include/ui_overlay_retraction_settings.h | 162 | Class declaration, subject + widget references |
src/ui/ui_overlay_retraction_settings.cpp | 293 | Lifecycle, event handlers, G-code send |
You’ll also touch four other files to wire it into the app:
src/xml_registration.cpp— register the XML componentsrc/application/subject_initializer.cpp— construct the global instance at bootui_xml/settings_printing_overlay.xml— add a row that opens the overlay- (optional) translation YAML if you added user-visible strings
Let’s walk through each.
Step 1: The XML layout (ui_xml/retraction_settings_overlay.xml)
Section titled “Step 1: The XML layout (ui_xml/retraction_settings_overlay.xml)”The top of every overlay XML looks like this:
<component> <view name="retraction_settings_overlay" extends="overlay_panel" width="#overlay_panel_width_full" title="Retraction Settings" title_tag="Retraction Settings" bg_color="#screen_bg"> <lv_obj name="overlay_content" width="100%" flex_grow="1" style_pad_all="#space_lg" scrollable="true" flex_flow="column" style_pad_gap="#space_lg"> <!-- sections go here --> </lv_obj> </view></component>Notes:
extends="overlay_panel"— inherits the standard overlay chrome: title bar, back button, backdrop. You almost always want this.title_tag="..."— the translation key.titleis the English source;title_tagis what gets looked up in other languages. Both must be present for user-visible strings.#overlay_panel_width_full,#space_lg,#screen_bg— design tokens. Never hardcode pixel widths or hex colors.- Inner
<lv_obj name="overlay_content">— the scrollable content area. Theoverlay_panelbase provides the frame; you provide what’s inside.
Inside the content area, each setting is structured consistently (lines 37–66 of the real file, slightly abbreviated):
<lv_obj name="retract_length_section" width="100%" height="content" style_pad_all="0" scrollable="false" flex_flow="column" style_pad_gap="#space_sm">
<!-- Top row: label on left, current value on right --> <lv_obj width="100%" height="content" style_pad_all="0" flex_flow="row" style_flex_main_place="space_between" style_flex_cross_place="center"> <lv_obj height="content" flex_flow="column" style_pad_gap="#space_xxs"> <text_body text="Retract Length" translation_tag="Retract Length"/> <text_small text="0.4-2mm direct drive, 4-6mm bowden" translation_tag="0.4-2mm direct drive, 4-6mm bowden"/> </lv_obj> <text_body name="retract_length_label" bind_text="retract_length_display"/> </lv_obj>
<!-- Slider row --> <lv_obj width="100%" height="50" flex_flow="row" style_flex_cross_place="center"> <text_small width="30" text="0"/> <lv_slider name="retract_length_slider" flex_grow="1" min_value="0" max_value="600" value="80"> <event_cb trigger="value_changed" callback="on_retraction_setting_changed"/> </lv_slider> <text_small width="40" text="6mm" translation_tag="6mm"/> </lv_obj></lv_obj><divider_horizontal/>Things worth noticing:
bind_text="retract_length_display"— this label’s text comes from a subject namedretract_length_display. The C++ side owns that subject and writes to it when the slider moves.<event_cb trigger="value_changed" callback="on_retraction_setting_changed"/>— when the slider value changes, call the C++ function registered under the nameon_retraction_setting_changed. Nolv_obj_add_event_cb()in C++ — ever.- Callback naming — the convention is
on_<component>_<action>. Generic names likeon_changedwill collide. (See lesson L039.) translation_tagon every user-visible string. Exception: product names, material codes, universal terms. (SeeCONTRIBUTOR_GOTCHAS.md.)<divider_horizontal/>— a semantic widget that applies the standard divider style. Don’t roll your own withlv_obj+border_width.
Step 2: The class declaration (include/ui_overlay_retraction_settings.h)
Section titled “Step 2: The class declaration (include/ui_overlay_retraction_settings.h)”The header declares a class that inherits OverlayBase:
class RetractionSettingsOverlay : public OverlayBase { public: explicit RetractionSettingsOverlay(MoonrakerAPI* api); ~RetractionSettingsOverlay() override;
// OverlayBase virtuals void init_subjects() override; lv_obj_t* create(lv_obj_t* parent) override; [[nodiscard]] const char* get_name() const override { return "Retraction Settings"; } void on_activate() override; void on_deactivate() override; void cleanup() override;
private: void send_retraction_settings(); void update_display_labels(); void sync_from_printer_state();
static void on_enabled_changed(lv_event_t* e); static void on_setting_changed(lv_event_t* e);
// Widget pointers — cached in create(), used by event handlers lv_obj_t* enable_switch_ = nullptr; lv_obj_t* retract_length_slider_ = nullptr; // ... etc
// Subject manager for automatic cleanup SubjectManager subjects_;
// Subjects that C++ writes to (label text) lv_subject_t retract_length_display_; // ... etc
// Backing buffers for string subjects char retract_length_buf_[16]; // ... etc
MoonrakerAPI* api_ = nullptr; bool syncing_from_state_ = false;};
// Global accessor — constructed once by subject_initializer at bootRetractionSettingsOverlay& get_global_retraction_settings();void init_global_retraction_settings(MoonrakerAPI* api);Patterns to internalize:
- Inherit
OverlayBase. It provideslifetime_(AsyncLifetimeGuard),overlay_root_, and virtual lifecycle hooks. Don’t reinvent overlay chrome. - Cache widget pointers in
create(). Don’t re-lookup in event handlers. SubjectManager subjects_— RAII wrapper for cleanup. When the overlay is destroyed,subjects_.deinit_all()handles it.- Static event handlers. LVGL callbacks are C-style function pointers. You’ll need a way to reach the instance from inside them — the retraction overlay uses a global accessor, but see the note below on why that’s legacy.
Note on the singleton pattern: The
get_global_retraction_settings()+init_global_retraction_settings()shape you see here — staticstd::unique_ptrconstructed at boot, lives for the app lifetime — is a legacy pattern. It exists because most overlays were written when the codebase was smaller and boot-time construction was simpler. Going forward, new overlays should prefer dynamic allocation: construct on open, destroy on close. Two reasons: (1) memory isn’t wasted on overlays the user never opens, critical on small devices like AD5M (14–20MB RAM budget), and (2) if multiple instances ever become useful, nothing in the design blocks that. If you’re writing a new overlay, don’t reach for the global accessor shape reflexively — instantiate from the row-click handler, hand ownership toNavigationManager, let it destroy on pop. See “Going forward” at the end of this section for the sketch.
Step 3: The implementation (src/ui/ui_overlay_retraction_settings.cpp)
Section titled “Step 3: The implementation (src/ui/ui_overlay_retraction_settings.cpp)”The interesting parts:
3a. Initialization (init_subjects())
void RetractionSettingsOverlay::init_subjects() { // Create the subjects the XML binds to UI_MANAGED_SUBJECT_STRING(retract_length_display_, retract_length_buf_, "0.0mm", "retract_length_display", subjects_); // ... same for the other three sliders ...
// Register the event callbacks the XML references by name lv_xml_register_event_cb(nullptr, "on_retraction_row_clicked", on_retraction_row_clicked); lv_xml_register_event_cb(nullptr, "on_retraction_enabled_changed", on_enabled_changed); lv_xml_register_event_cb(nullptr, "on_retraction_setting_changed", on_setting_changed);}Two things happen here:
- Subject creation.
UI_MANAGED_SUBJECT_STRINGcreates a string subject, registers it globally under the given name, and tellssubjects_to clean it up on destruction. The XML’sbind_text="retract_length_display"resolves to this subject. - Callback registration. Every string in
<event_cb callback="...">must be registered withlv_xml_register_event_cb()before the XML is parsed. If the callback string doesn’t match a registered name, the event silently does nothing — a common first-time mistake. (SeeCONTRIBUTOR_GOTCHAS.md.)
3b. Widget creation (create())
lv_obj_t* RetractionSettingsOverlay::create(lv_obj_t* parent) { overlay_root_ = (lv_obj_t*)lv_xml_create(parent, get_xml_component_name(), nullptr); if (!overlay_root_) return nullptr;
enable_switch_ = lv_obj_find_by_name(overlay_root_, "retraction_enabled_switch"); retract_length_slider_ = lv_obj_find_by_name(overlay_root_, "retract_length_slider"); // ... etc
return overlay_root_;}lv_xml_create() parses your XML component into a real widget tree. After that, use lv_obj_find_by_name() to locate widgets you need to poke at from C++ (sliders for reading values, switches for setting checked state). The name="..." attributes in your XML are what make this lookup work — give a name to anything you’ll touch from C++.
Never use lv_obj_get_child() — it’s index-based and fragile. If someone reorders the XML, your code breaks silently.
3c. The lifecycle hooks
void RetractionSettingsOverlay::on_activate() { OverlayBase::on_activate(); sync_from_printer_state();}on_activate() runs every time the overlay becomes visible. For a settings overlay, this is where you pull the current values from PrinterState and seed the UI. on_deactivate() runs when it’s dismissed — clean up timers, stop polling, etc.
3d. Event handlers
void RetractionSettingsOverlay::on_setting_changed(lv_event_t* /*e*/) { auto& overlay = get_global_retraction_settings(); overlay.update_display_labels();
if (overlay.syncing_from_state_) return; overlay.send_retraction_settings();}Notice the syncing_from_state_ guard. Without it, calling lv_slider_set_value() inside sync_from_printer_state() would fire value_changed, which would then send a G-code, which the printer would echo back, which would… it’s a feedback loop. Flag-guard sync paths whenever the source-of-truth is external.
3e. The “open me” callback
static void on_retraction_row_clicked(lv_event_t* /*e*/) { if (!g_retraction_settings) return;
if (!g_retraction_settings_panel) { g_retraction_settings_panel = g_retraction_settings->create(lv_display_get_screen_active(nullptr)); NavigationManager::instance().register_overlay_instance( g_retraction_settings_panel, g_retraction_settings.get()); }
NavigationManager::instance().push_overlay(g_retraction_settings_panel);}Lazy-create on first open (fast boot), reuse thereafter. NavigationManager::push_overlay() handles animation, backdrop, and the activation lifecycle.
Step 4: Wire it into the app
Section titled “Step 4: Wire it into the app”Four one-line edits:
4a. Register the XML component — src/xml_registration.cpp:
register_xml("retraction_settings_overlay.xml");Without this, lv_xml_create("retraction_settings_overlay", ...) returns null. Put it near similar overlays, not at random.
4b. Construct the global instance at boot — src/application/subject_initializer.cpp:
init_global_retraction_settings(api);This is where every overlay’s init_global_*() gets called during app startup. Order can matter if overlays depend on each other — follow the pattern of siblings.
4c. Add the row that opens it — ui_xml/settings_printing_overlay.xml:
<lv_obj name="container_firmware_retraction" width="100%" style_pad_all="0" scrollable="false"> <bind_flag_if_eq subject="printer_has_firmware_retraction" flag="hidden" ref_value="0"/> <setting_action_row name="row_firmware_retraction" label="Retraction Settings" label_tag="Retraction Settings" icon="arrow_up_right" description="Configure G10/G11 firmware retraction" description_tag="Configure G10/G11 firmware retraction" callback="on_retraction_row_clicked"/></lv_obj>The <bind_flag_if_eq> wrapper hides the row on printers that don’t support firmware retraction — this is how you conditionally expose features without a runtime C++ check.
4d. Run it
make -j./build/bin/helix-screen --test -vvNavigate to Settings → Printing → Retraction Settings. If nothing appears: check CONTRIBUTOR_GOTCHAS.md.
The full shape
Section titled “The full shape”ui_xml/ retraction_settings_overlay.xml ← your layout settings_printing_overlay.xml ← edit to add the row
include/ ui_overlay_retraction_settings.h ← your class
src/ui/ ui_overlay_retraction_settings.cpp ← your impl
src/ xml_registration.cpp ← edit to register_xml() application/subject_initializer.cpp ← edit to call init_global_*()That’s the full shape of a real settings overlay contribution. Every other overlay in src/ui/ui_overlay_*.cpp and every ui_settings_*.cpp follows this same pattern. If you’re stuck, find the closest sibling, open its three files side-by-side, and diff against yours.
Going forward: dynamic overlays
Section titled “Going forward: dynamic overlays”The retraction overlay is the pattern the codebase has. Here’s the pattern it’s moving toward — use this shape for new overlays:
What changes:
- No global accessor, no
init_global_*. Drop thestatic std::unique_ptr<...> g_instanceat the top of the .cpp file and the call site insubject_initializer.cpp. - Construct from the row-click handler. When the user taps the row that opens your overlay, instantiate on the spot and hand ownership to
NavigationManager. - Destroy on pop. When the overlay is dismissed, the NavigationManager destroys the instance. Next open creates a fresh one.
- Event handlers need an instance pointer, not a global lookup. The cleanest path is to capture
thisin the<event_cb>registration by using an instance method or a small adapter, so handlers don’t needget_global_*()at all. Non-static member callbacks registered per-instance are preferred over static free functions that reach into globals.
What stays the same:
- Inheriting
OverlayBase. - The
init_subjects()/create()/on_activate()/on_deactivate()/cleanup()lifecycle. - The XML structure — this is purely a C++ pattern change.
SubjectManagerfor subject cleanup.
Why this matters:
- Memory. On AD5M (14–20MB RAM budget) and similar small devices, an overlay the user never opens shouldn’t occupy any bytes. Lazy-global saved boot time but pays a permanent memory tax.
- Multi-instance becomes possible. Nothing in the design forces one-instance-per-app. If two contexts ever want the same overlay shape with different data, dynamic allocation supports it; the global pattern doesn’t.
- Teardown is simpler. No
StaticPanelRegistry::register_destroydance at shutdown. The overlay destroys when popped, full stop.
This pattern isn’t yet established in code — if you’re writing a new overlay, you get to set the precedent. Flag it in your PR and Preston will help shape the exact API. Migrating the existing singleton overlays is tracked as post-1.0 work (see ROADMAP.md § “Planned post-1.0 refactoring”).
Part 2: Scaling up — how AMS is organized
Section titled “Part 2: Scaling up — how AMS is organized”The AMS (Automatic Material System) panel is what a full-subsystem contribution looks like. You won’t write one of these on your first PR, but knowing how it’s decomposed helps you read it when you need to touch adjacent code, and it’s the shape to aim for if you’re proposing something big.
AMS spans roughly 1700 lines of C++ (src/ui/ui_panel_ams.cpp) and 11 XML files. It’s not monolithic — it’s a set of small components that compose:
| XML component | Role |
|---|---|
ams_panel.xml | Root panel — title bar, unit carousel, active tool display |
ams_unit_card.xml | Rendering of a single AMS unit (e.g., one AMS Lite) |
ams_slot_view.xml | One filament slot inside a unit |
ams_current_tool.xml | ”Currently loaded” display at the top |
ams_device_section_detail.xml | Detail pane showing humidity, temp, etc. |
ams_device_operations.xml | Action buttons (load, unload, purge) |
ams_edit_modal.xml | Edit filament assigned to a slot |
ams_context_menu.xml | Long-press context menu |
ams_loading_error_modal.xml | Error state dialog |
ams_environment_overlay.xml | Humidity / temp details |
ams_overview_panel.xml | Summary view for multi-unit setups |
Patterns worth internalizing from AMS:
1. One XML component per visual primitive
Section titled “1. One XML component per visual primitive”The unit card, slot, operations panel, and current tool are each their own .xml file with their own <component> block. This means they can be:
- Instantiated from multiple places (the current tool display reuses the same look as a slot).
- Styled and laid out independently at different breakpoints.
- Tested and iterated on without re-rendering the whole panel.
If you’re composing a feature, every distinct visual piece should be its own XML component. The small cost of an extra file is paid back the first time you want to reuse or restyle it.
2. State lives in a backend class, not the panel
Section titled “2. State lives in a backend class, not the panel”AMS state (which units exist, what filament is in each slot, humidity readings) lives in AmsState (src/printer/ams_state.cpp) — a singleton updated by the Moonraker WebSocket thread. The panel observes subjects published by AmsState and renders reactively. The panel never stores “the truth” — it only displays what the state says.
This separation is what lets the panel be destroyed and recreated without the app losing its AMS knowledge.
3. Dynamic collections use parallel observer vectors
Section titled “3. Dynamic collections use parallel observer vectors”AMS units can appear and disappear at runtime (plug in a second unit, a firmware update changes the layout). The panel holds:
std::vector<ObserverGuard> slot_observers_;std::vector<SubjectLifetime> slot_lifetimes_;These are kept aligned — pushed and popped in lockstep. When the set of units changes, lifetimes are cleared before observers. This is the pattern for any per-item observer collection; see lesson L084 and the top of ui_panel_ams.cpp for a real example.
4. Multi-backend abstraction
Section titled “4. Multi-backend abstraction”AMS isn’t one system — it’s AMS (Bambu), AFC (Box Turtle), Happy Hare, ACE (Anycubic), AD5X IFS, CFS (Creality), and Tool Changers, all surfaced through the same UI. The abstraction lives in AmsState, which normalizes all backends to a common shape. The panel knows only the normalized shape.
If you’re adding a new filament backend, the pattern is: implement a backend class under src/printer/ that publishes the same subjects AmsState expects. The existing panel picks it up for free. See docs/devel/FILAMENT_MANAGEMENT.md for the deep dive.
5. Modals and overlays are peers, not children
Section titled “5. Modals and overlays are peers, not children”ams_edit_modal.xml and ams_environment_overlay.xml are standalone classes (Modal subclass and OverlayBase subclass respectively). The main AMS panel launches them via NavigationManager::push_overlay() or Modal::show() — it doesn’t own their widgets. This keeps each concern bounded.
Part 3: The workflow
Section titled “Part 3: The workflow”Branch
Section titled “Branch”Follow GitHub naming: feature/<short-name> for new features, fix/<short-name> for bugs.
git switch -c feature/my-contributionFor anything that spans more than a handful of files, use a worktree:
scripts/setup-worktree.sh feature/my-contributionBuild and test
Section titled “Build and test”make -j # build binary only./build/bin/helix-screen --test -vv # run with mock printer, verbose logsmake test-run # build and run the full test suiteFor UI iteration without rebuilding after every XML edit:
HELIX_HOT_RELOAD=1 ./build/bin/helix-screen --test -vv# edit XML → save → switch panels → see changes liveTest at multiple breakpoints before submitting. At minimum: -s 480x320, -s 800x480, -s 1024x600. See UI_CONTRIBUTOR_GUIDE.md § Screen Breakpoints.
Commit style
Section titled “Commit style”- Subject line:
type(scope): summary— e.g.,feat(retraction): add settings overlay. - For bug fixes, include the GitHub issue reference:
fix(scope): thing (prestonbrown/helixscreen#123). - Keep commits focused. One logical change per commit.
Before you submit
Section titled “Before you submit”Work through CONTRIBUTOR_GOTCHAS.md § “Before You Submit”. It’s a pre-flight checklist for the silent-failure traps.
Open a PR on prestonbrown/helixscreen — that’s how we coordinate changes, discuss tradeoffs, and keep a paper trail of decisions. Describe what the change does and why, and link any related issues. Screenshots of UI changes at multiple breakpoints go a long way.
Further reading
Section titled “Further reading”Once the retraction overlay makes sense, these are the next docs to read in order:
| Read next | Why |
|---|---|
CONTRIBUTOR_GOTCHAS.md | The “if you see X, you forgot Y” reference for silent failures |
UI_CONTRIBUTOR_GUIDE.md | Deeper on breakpoints, tokens, semantic widgets |
LVGL9_XML_GUIDE.md | Full XML attribute and widget reference |
MODAL_SYSTEM.md | If your contribution is a modal rather than an overlay |
DEVELOPER_QUICK_REFERENCE.md | Code patterns for specific scenarios |
TRANSLATION_SYSTEM.md | When you add user-visible strings |
And if you hit a wall, the fastest debug is: find the closest-shaped sibling in src/ui/ and diff your code against it.