Component Architecture

June 25, 2026 · View on GitHub

This document explains Thoth's component architecture and the one-way data binding pattern used for building UI components.

Overview

Thoth uses a trait-based component system inspired by React's component model, adapted to work with Rust's ownership system and egui's immediate mode GUI pattern.

Two tiers of UI. Since the SDK migration, Thoth's UI is split in two:

  1. Reusable widgets (Button, Input, Select, List, Card, DataRow, Tabs, …) live in the thoth-plugin-sdk crate as owned, serializable bon builders, rendered via ui.add(widget) (stateless) or widget.show(&mut ui, …) (stateful). The same types are shared by the host and by plugins (which serialize them to JSON). See PLUGIN_SYSTEM.md.
  2. Feature panels (Toolbar, Sidebar, Status bar, Settings tabs, Search, the file viewers, …) remain host components implementing the Stateless/Stateful/ContextComponent traits below, composing the SDK widgets. The one-way data-binding pattern documented here applies to these.

Rule of thumb: a small reusable widget → add it to the SDK; a panel that wires widgets to app state and events → use the trait pattern here.

Component Traits

We have two main component traits, each serving a different purpose:

1. StatelessComponent

Pure functional components that don't maintain state between renders.

pub trait StatelessComponent {
    type Props;
    type Output;

    fn render(ui: &mut egui::Ui, props: Self::Props) -> Self::Output;
}

Use cases: small host-only panels that just read props and render (no state).

Reusable widgets like buttons, icons, inputs, and lists are not written as StatelessComponents anymore — they live in the SDK and are added with ui.add(Button::builder()…build()). Reach for StatelessComponent only for a host panel that has no internal state of its own.

Example (illustrative — a stateless host panel composing an SDK widget):

use thoth_plugin_sdk::components::{Button, ButtonColor};

struct ClearRow;

impl StatelessComponent for ClearRow {
    type Props = bool; // enabled
    type Output = bool; // clicked

    fn render(ui: &mut egui::Ui, enabled: Self::Props) -> Self::Output {
        ui.add(
            Button::builder()
                .label("Clear")
                .color(ButtonColor::Danger)
                .enabled(enabled)
                .build(),
        )
        .clicked()
    }
}

2. StatefulComponent

Components that maintain internal state between renders but don't need full Context access.

pub trait StatefulComponent {
    type Props<'a>;
    type Output;

    fn render(&mut self, ui: &mut egui::Ui, props: Self::Props<'_>) -> Self::Output;
}

Use cases: Search panels, settings panels, forms with internal state

Example:

struct SearchPanel {
    query: String,
    match_case: bool,
}

pub struct SearchPanelProps {}

impl StatefulComponent for SearchPanel {
    type Props<'a> = SearchPanelProps;
    type Output = SearchPanelOutput;

    fn render(&mut self, ui: &mut egui::Ui, _props: Self::Props<'_>) -> Self::Output {
        ui.text_edit_singleline(&mut self.query);
        ui.checkbox(&mut self.match_case, "Match case");

        SearchPanelOutput {
            events: vec![SearchEvent::QueryChanged(self.query.clone())]
        }
    }
}

3. ContextComponent

Components that need access to the full egui::Context to create top-level panels.

pub trait ContextComponent {
    type Props<'a>;
    type Output;

    fn render(&mut self, ctx: &egui::Context, props: Self::Props<'_>) -> Self::Output;
}

Use cases: Toolbar, Settings panel, Central panel, any top-level UI

One-Way Data Binding Pattern

Our component architecture follows a one-way data binding pattern similar to React:

┌─────────────┐
│   Parent    │
│ Component   │
└─────────────┘

      │ Props (immutable)

┌─────────────┐
│    Child    │
│ Component   │
└─────────────┘

      │ Events (actions)

┌─────────────┐
│   Parent    │
│   Handles   │
└─────────────┘

Data Flow

  1. Props flow down (parent → child): Immutable data passed as references
  2. Events flow up (child → parent): Actions/events returned in Output

This pattern avoids Rust's borrow checker conflicts that arise when trying to use callbacks with mutable closures.

Example: Toolbar Component

Let's walk through a complete example using the Toolbar component.

Step 1: Define Props

Props are immutable data passed from parent to child:

pub struct ToolbarProps<'a> {
    pub file_path: &'a Option<PathBuf>,
    pub file_type: &'a FileType,
    pub dark_mode: bool,
    pub show_settings: bool,
    pub update_available: bool,
    pub shortcuts: &'a KeyboardShortcuts,
}

Key points:

  • Use references (&'a) for borrowed data
  • Use owned types (bool, small types) for values
  • All fields are immutable from child's perspective

Step 2: Define Events

Events represent actions that occurred in the child:

pub enum ToolbarEvent {
    FileOpen { path: PathBuf, file_type: FileType },
    FileClear,
    NewWindow,
    FileTypeChange(FileType),
    ToggleSettings,
    ToggleTheme,
}

Key points:

  • Each variant represents a user action
  • Include necessary data with the event
  • Parent decides how to handle each event

Step 3: Define Output

Output contains both immediate results and events:

pub struct ToolbarOutput {
    pub search_message: Option<SearchMessage>,
    pub events: Vec<ToolbarEvent>,
}

Step 4: Implement the Component

impl ContextComponent for Toolbar {
    type Props<'a> = ToolbarProps<'a>;
    type Output = ToolbarOutput;

    fn render(&mut self, ctx: &egui::Context, props: Self::Props<'_>) -> Self::Output {
        let mut events = Vec::new();
        let search_message = self.render_ui(ctx, props, &mut events);

        ToolbarOutput {
            search_message,
            events,
        }
    }
}

impl Toolbar {
    fn render_ui(
        &mut self,
        ctx: &egui::Context,
        props: ToolbarProps<'_>,
        events: &mut Vec<ToolbarEvent>,
    ) -> Option<SearchMessage> {
        // Read from props (immutable)
        let file_type = *props.file_type;
        let dark_mode = props.dark_mode;

        // Emit events when actions occur
        if ui.button("Clear").clicked() {
            events.push(ToolbarEvent::FileClear);
        }

        if ui.checkbox(&mut dark_mode_copy, "Dark").changed() {
            events.push(ToolbarEvent::ToggleTheme);
        }

        // Return immediate results
        Some(search_message)
    }
}

Step 5: Use in Parent

fn render_toolbar(&mut self, ctx: &egui::Context) -> Option<SearchMessage> {
    // Render with props (data flows down)
    let output = self.window_state.toolbar.render(
        ctx,
        ToolbarProps {
            file_path: &self.window_state.file_path,
            file_type: &self.window_state.file_type,
            dark_mode: self.settings.dark_mode,
            show_settings: self.settings_panel.show,
            update_available: self.update_available,
            shortcuts: &self.settings.shortcuts,
        },
    );

    // Handle events (actions flow up)
    for event in output.events {
        match event {
            ToolbarEvent::FileOpen { path, file_type } => {
                self.window_state.file_path = Some(path);
                self.window_state.file_type = file_type;
                self.window_state.error = None;
            }
            ToolbarEvent::FileClear => {
                self.window_state.file_path = None;
                self.window_state.error = None;
            }
            ToolbarEvent::ToggleTheme => {
                self.settings.dark_mode = !self.settings.dark_mode;
            }
            // ... handle other events
        }
    }

    output.search_message
}

Benefits of This Approach

1. Borrow Checker Friendly

Unlike callback-based approaches, this pattern doesn't create conflicting borrows:

// ❌ This doesn't work in Rust:
ToolbarProps {
    file_path: &self.file_path,           // immutable borrow
    on_clear: &mut || {
        self.file_path = None;            // mutable borrow - CONFLICT!
    }
}

// ✅ This works:
let output = toolbar.render(ctx, ToolbarProps {
    file_path: &self.file_path,           // immutable borrow
});
for event in output.events {              // mutable borrow later
    match event {
        ToolbarEvent::Clear => self.file_path = None,
    }
}

2. Type Safety

The compiler ensures all events are handled:

match event {
    ToolbarEvent::FileOpen { .. } => { /* ... */ }
    ToolbarEvent::FileClear => { /* ... */ }
    // Compiler error if you forget an event variant!
}

3. Testability

Easy to test components in isolation:

#[test]
fn test_toolbar_file_clear() {
    let mut toolbar = Toolbar::default();
    let props = ToolbarProps { /* ... */ };

    let output = toolbar.render(ctx, props);

    assert!(output.events.contains(&ToolbarEvent::FileClear));
}

4. Performance

  • No unnecessary cloning of data
  • Props are references (zero-cost)
  • Events only created when actions occur

5. Familiar Pattern

Developers familiar with React will recognize this pattern:

ReactThoth
Props (read-only)Props<'a> struct
StateComponent's internal fields
CallbacksEvent enum variants
onChange={handler}Match on event enum

Best Practices

1. Keep Props Small

Only pass what the component needs:

// ✅ Good: Only relevant data
pub struct ButtonProps<'a> {
    pub label: &'a str,
    pub disabled: bool,
}

// ❌ Bad: Passing entire app state
pub struct ButtonProps<'a> {
    pub app_state: &'a AppState,
}

2. Use Descriptive Event Names

// ✅ Good: Clear intent
pub enum ToolbarEvent {
    FileOpen { path: PathBuf },
    FileTypeChanged(FileType),
    SearchRequested(String),
}

// ❌ Bad: Vague
pub enum ToolbarEvent {
    Action1(PathBuf),
    Changed(FileType),
    Event(String),
}

3. Include Necessary Data in Events

// ✅ Good: Event is self-contained
pub enum EditorEvent {
    TextChanged { new_text: String, cursor_pos: usize },
}

// ❌ Bad: Parent needs to fetch data
pub enum EditorEvent {
    TextChanged, // Parent has to query: "what's the new text?"
}

4. Don't Over-Event

Not every interaction needs an event:

// ✅ Good: Only emit events that affect parent state
if ui.button("Save").clicked() {
    events.push(EditorEvent::SaveRequested);
}

// ❌ Bad: Events for internal state
if ui.button("Hover me").hovered() {
    events.push(EditorEvent::ButtonHovered); // Parent doesn't care!
}

5. Use Lifetimes Appropriately

// ✅ Good: Single lifetime for related references
pub struct Props<'a> {
    pub name: &'a str,
    pub items: &'a [Item],
}

// ❌ Usually unnecessary: Multiple lifetimes
pub struct Props<'a, 'b> {
    pub name: &'a str,
    pub items: &'b [Item],
}

Comparison with Other Patterns

vs. Callbacks (Why not on_change: &mut dyn FnMut(...)?)

Callbacks have borrow checker issues:

// ❌ Borrow checker conflict
let output = component.render(ctx, Props {
    value: &self.value,        // immutable borrow
    on_change: &mut |v| {
        self.value = v;        // mutable borrow - ERROR!
    }
});

Events solve this:

// ✅ No conflict - borrows happen at different times
let output = component.render(ctx, Props {
    value: &self.value,        // immutable borrow ends here
});
for event in output.events {   // mutable borrow starts here
    self.value = event.new_value;
}

vs. Message Passing (Why not channels?)

Channels add unnecessary complexity for UI:

// ❌ Overkill for immediate UI events
let (tx, rx) = mpsc::channel();
component.render(ctx, Props { sender: tx });
while let Ok(event) = rx.try_recv() {
    handle_event(event);
}

// ✅ Simpler and more direct
let output = component.render(ctx, props);
for event in output.events {
    handle_event(event);
}

vs. Direct Mutation (Why not pass &mut props?)

Breaking encapsulation:

// ❌ Child directly mutates parent state
component.render(ctx, Props {
    value: &mut self.value,  // Child can change it directly
});

// ✅ Parent controls when/how state changes
let output = component.render(ctx, Props {
    value: &self.value,      // Read-only
});
for event in output.events {
    // Parent decides what to do
    self.value = transform(event);
}

Migration Guide

When refactoring an existing component to use traits:

Before (ad-hoc)

pub struct Toolbar { /* ... */ }

impl Toolbar {
    pub fn ui(&mut self, ctx: &egui::Context, state: &mut AppState) -> Output {
        // Directly mutates state
        if ui.button("Clear").clicked() {
            state.file_path = None;
        }
    }
}

After (trait-based)

pub struct Toolbar { /* ... */ }

pub struct ToolbarProps<'a> {
    pub file_path: &'a Option<PathBuf>,
}

pub enum ToolbarEvent {
    FileClear,
}

pub struct ToolbarOutput {
    pub events: Vec<ToolbarEvent>,
}

impl ContextComponent for Toolbar {
    type Props<'a> = ToolbarProps<'a>;
    type Output = ToolbarOutput;

    fn render(&mut self, ctx: &egui::Context, props: Self::Props<'_>) -> Self::Output {
        let mut events = Vec::new();

        if ui.button("Clear").clicked() {
            events.push(ToolbarEvent::FileClear);
        }

        ToolbarOutput { events }
    }
}

// In parent:
let output = toolbar.render(ctx, ToolbarProps {
    file_path: &state.file_path,
});
for event in output.events {
    match event {
        ToolbarEvent::FileClear => state.file_path = None,
    }
}

Further Reading

Examples in Codebase

  • Toolbar: src/components/toolbar.rs - Full example of ContextComponent
  • Settings tabs: src/components/settings_dialog/*.rs - StatelessComponent panels
  • Search / Bookmarks: src/components/search.rs, bookmarks.rs - StatefulComponent panels
  • FileFormatViewer: src/components/file_viewer/viewer_trait.rs - Specialized trait pattern
  • Component Traits: src/components/common/traits.rs - Trait definitions
  • Reusable widgets: thoth-plugin-sdk/src/components/ - the shared widget library these panels compose (run the gallery: cargo run -p thoth-plugin-sdk --example gallery --features egui)

Contributing

When adding new components:

  1. Choose the appropriate trait (Stateless, Stateful, or Context)
  2. Define clear Props and Event types
  3. Document the component's purpose and usage
  4. Add examples to this document if it introduces new patterns

Questions or suggestions? Open an issue or discussion on GitHub!