Widget Trait

This page documents the Widget trait, which defines the interface that all TUI widgets must implement. Understanding this trait is essential for creating custom widgets.

Trait Definition

pub trait Widget: Send + 'static {
    /// Unique identifier for the widget
    fn id(&self) -> &'static str;

    /// Priority for key event handling (higher = first)
    fn priority(&self) -> u8 {
        100
    }

    /// Whether the widget is currently active/visible
    fn is_active(&self) -> bool;

    /// Handle key events
    fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult;

    /// Render the widget to the frame
    fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme);

    /// Required height for layout computation
    fn required_height(&self, available: u16) -> u16 {
        0
    }

    /// Whether this widget blocks text input
    fn blocks_input(&self) -> bool {
        false
    }

    /// Whether this is a full-screen overlay
    fn is_overlay(&self) -> bool {
        false
    }

    /// Downcasting support
    fn as_any(&self) -> &dyn Any;
    fn as_any_mut(&mut self) -> &mut dyn Any;
    fn into_any(self: Box<Self>) -> Box<dyn Any>;
}

Required Methods

id()

Returns the unique identifier for the widget:

fn id(&self) -> &'static str;
  • Must be a &'static str (compile-time constant)
  • Used as HashMap key for widget storage
  • Should be unique across all widgets
// Example
pub const MY_WIDGET: &str = "my_widget";

fn id(&self) -> &'static str {
    MY_WIDGET
}

is_active()

Returns whether the widget should participate in rendering and key handling:

fn is_active(&self) -> bool;
  • Inactive widgets are skipped during rendering
  • Inactive widgets do not receive key events
  • Controlled by widget internal state
fn is_active(&self) -> bool {
    self.active
}

handle_key()

Handles key events for the widget:

fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult;

Parameters:

  • key: The key event (code + modifiers)
  • ctx: Context with theme and navigation helpers

Returns:

  • NotHandled: Key not consumed, continue dispatch
  • Handled: Key consumed, stop dispatch
  • Action(WidgetAction): Request app-level action
fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
    if ctx.nav.is_up(key) {
        self.move_selection_up();
        return WidgetKeyResult::Handled;
    }
    if ctx.nav.is_select(key) {
        return WidgetKeyResult::Action(WidgetAction::Close);
    }
    WidgetKeyResult::NotHandled
}

render()

Renders the widget to the given area:

fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme);

Parameters:

  • frame: Ratatui frame for rendering
  • area: Allocated area for this widget
  • theme: Current theme for styling
fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
    let block = Block::default()
        .title(self.title)
        .borders(Borders::ALL)
        .border_style(theme.border);

    let inner = block.inner(area);
    frame.render_widget(block, area);

    let content = Paragraph::new(&self.content)
        .style(theme.text);
    frame.render_widget(content, inner);
}

Downcasting Methods

Required for type-erased widget storage:

fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
fn into_any(self: Box<Self>) -> Box<dyn Any>;

Standard Implementation:

fn as_any(&self) -> &dyn Any {
    self
}

fn as_any_mut(&mut self) -> &mut dyn Any {
    self
}

fn into_any(self: Box<Self>) -> Box<dyn Any> {
    self
}

These enable downcasting from Box<dyn Widget> to concrete types.

Optional Methods

priority()

Determines key event handling order:

fn priority(&self) -> u8 {
    100  // Default
}

Higher priority widgets receive key events first:

PriorityUse Case
120+Overlays (theme picker, session picker)
100-119Modal widgets (permission, question panels)
50-99Interactive content
10-49Display-only widgets
0-9Background widgets

required_height()

Returns the height needed for layout:

fn required_height(&self, available: u16) -> u16 {
    0  // Default: takes no space
}

Parameters:

  • available: Total available height

Returns:

  • Fixed height for the widget
  • 0 for flexible widgets (filled by layout)
// Fixed height widget
fn required_height(&self, _available: u16) -> u16 {
    self.config.height  // e.g., 2 for status bar
}

// Dynamic height widget
fn required_height(&self, available: u16) -> u16 {
    (self.items.len() as u16).min(available / 2)
}

blocks_input()

Whether this widget blocks text input:

fn blocks_input(&self) -> bool {
    false  // Default
}

When true:

  • Text input widget is disabled
  • Keys go to this widget instead
  • Used by modal widgets
// Modal widget blocks input
fn blocks_input(&self) -> bool {
    self.is_active()
}

is_overlay()

Whether this is a full-screen overlay:

fn is_overlay(&self) -> bool {
    false  // Default
}

Overlays:

  • Rendered last (on top of everything)
  • Typically cover the entire screen
  • Block all other interactions
// Full-screen picker
fn is_overlay(&self) -> bool {
    true
}

Key Event Context

The context passed to handle_key():

pub struct WidgetKeyContext<'a> {
    pub theme: &'a Theme,
    pub nav: NavigationHelper<'a>,
}

Respects configured key bindings:

impl NavigationHelper<'_> {
    pub fn is_up(&self, key: KeyEvent) -> bool;
    pub fn is_down(&self, key: KeyEvent) -> bool;
    pub fn is_left(&self, key: KeyEvent) -> bool;
    pub fn is_right(&self, key: KeyEvent) -> bool;
    pub fn is_select(&self, key: KeyEvent) -> bool;
    pub fn is_cancel(&self, key: KeyEvent) -> bool;
    pub fn is_tab(&self, key: KeyEvent) -> bool;
}

Using these helpers ensures widgets work with vim-style and arrow key bindings.

Widget Key Result

Return values from handle_key():

pub enum WidgetKeyResult {
    NotHandled,
    Handled,
    Action(WidgetAction),
}
ResultEffect
NotHandledContinue to next widget
HandledStop dispatch, key consumed
Action(action)Request app-level action

Widget Actions

Actions that widgets can request:

pub enum WidgetAction {
    SubmitQuestion {
        tool_use_id: String,
        response: AskUserQuestionsResponse,
    },
    CancelQuestion {
        tool_use_id: String,
    },
    SubmitPermission {
        tool_use_id: String,
        response: PermissionResponse,
    },
    CancelPermission {
        tool_use_id: String,
    },
    SwitchSession {
        session_id: i64,
    },
    ExecuteCommand {
        command: String,
    },
    Close,
}

Trait Bounds

pub trait Widget: Send + 'static
BoundPurpose
SendWidget can be sent between threads
'staticWidget has no borrowed references

Complete Example

pub struct SelectionWidget {
    id: &'static str,
    active: bool,
    items: Vec<String>,
    selected: usize,
}

impl SelectionWidget {
    pub fn new(id: &'static str, items: Vec<String>) -> Self {
        Self {
            id,
            active: false,
            items,
            selected: 0,
        }
    }

    pub fn activate(&mut self) {
        self.active = true;
        self.selected = 0;
    }

    pub fn deactivate(&mut self) {
        self.active = false;
    }
}

impl Widget for SelectionWidget {
    fn id(&self) -> &'static str {
        self.id
    }

    fn priority(&self) -> u8 {
        105  // Above content, below overlays
    }

    fn is_active(&self) -> bool {
        self.active
    }

    fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
        if ctx.nav.is_up(key) {
            if self.selected > 0 {
                self.selected -= 1;
            }
            return WidgetKeyResult::Handled;
        }
        if ctx.nav.is_down(key) {
            if self.selected < self.items.len() - 1 {
                self.selected += 1;
            }
            return WidgetKeyResult::Handled;
        }
        if ctx.nav.is_select(key) {
            let item = self.items[self.selected].clone();
            return WidgetKeyResult::Action(WidgetAction::ExecuteCommand {
                command: item,
            });
        }
        if ctx.nav.is_cancel(key) {
            self.deactivate();
            return WidgetKeyResult::Action(WidgetAction::Close);
        }
        WidgetKeyResult::NotHandled
    }

    fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
        let block = Block::default()
            .title(" Select ")
            .borders(Borders::ALL);

        let inner = block.inner(area);
        frame.render_widget(block, area);

        let items: Vec<Line> = self.items.iter().enumerate()
            .map(|(i, item)| {
                let style = if i == self.selected {
                    theme.selected
                } else {
                    theme.normal
                };
                Line::from(item.as_str()).style(style)
            })
            .collect();

        let list = Paragraph::new(items);
        frame.render_widget(list, inner);
    }

    fn required_height(&self, _available: u16) -> u16 {
        (self.items.len() + 2) as u16  // Items + border
    }

    fn blocks_input(&self) -> bool {
        self.active
    }

    fn as_any(&self) -> &dyn Any { self }
    fn as_any_mut(&mut self) -> &mut dyn Any { self }
    fn into_any(self: Box<Self>) -> Box<dyn Any> { self }
}

Next Steps