Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Adding Widgets

Guide to creating new widget types for Two-Face.

Overview

Widgets are the visual building blocks of Two-Face. Each widget type:

  • Displays specific data
  • Has its own rendering logic
  • Responds to state changes
  • May handle user input

Widget Architecture

Widget Lifecycle

Configuration → Creation → State Sync → Render → Input (optional)
     │             │            │          │           │
     │             │            │          │           └─ Handle focus/keys
     │             │            │          └─ Draw to terminal frame
     │             │            └─ Check generation, update data
     │             └─ Instantiate from config
     └─ TOML defines type and properties

Key Traits

Widgets implement several traits:

#![allow(unused)]
fn main() {
// Core widget behavior
pub trait Widget {
    fn render(&self, frame: &mut Frame, area: Rect);
    fn name(&self) -> &str;
    fn can_focus(&self) -> bool;
}

// State synchronization
pub trait Syncable {
    fn sync(&mut self, state: &AppState) -> bool;
    fn last_generation(&self) -> u64;
}

// Input handling (optional)
pub trait Interactive {
    fn handle_key(&mut self, key: KeyEvent) -> bool;
    fn handle_mouse(&mut self, mouse: MouseEvent) -> bool;
}
}

Step-by-Step: New Widget

Let’s create a “SpellTimer” widget that displays active spell durations.

Step 1: Define the Widget Type

Add to src/data/widget.rs:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum WidgetType {
    Text,
    Progress,
    Compass,
    // ... existing types ...
    SpellTimer,  // Add new type
}
}

Step 2: Create Widget Configuration

#![allow(unused)]
fn main() {
// In src/data/widget.rs or dedicated file

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpellTimerConfig {
    pub name: String,
    pub x: u16,
    pub y: u16,
    pub width: u16,
    pub height: u16,

    // Widget-specific options
    pub show_duration: bool,
    pub group_by_circle: bool,
    pub max_display: usize,
}

impl Default for SpellTimerConfig {
    fn default() -> Self {
        Self {
            name: "spell_timer".into(),
            x: 0,
            y: 0,
            width: 20,
            height: 10,
            show_duration: true,
            group_by_circle: false,
            max_display: 10,
        }
    }
}
}

Step 3: Create Widget Structure

Create src/frontend/tui/spell_timer.rs:

#![allow(unused)]
fn main() {
use crate::core::AppState;
use crate::data::SpellTimerConfig;
use ratatui::prelude::*;
use ratatui::widgets::*;

pub struct SpellTimerWidget {
    config: SpellTimerConfig,
    spells: Vec<ActiveSpell>,
    last_generation: u64,
}

#[derive(Clone)]
struct ActiveSpell {
    name: String,
    duration: u32,
    circle: u8,
}

impl SpellTimerWidget {
    pub fn new(config: SpellTimerConfig) -> Self {
        Self {
            config,
            spells: Vec::new(),
            last_generation: 0,
        }
    }
}
}

Step 4: Implement Widget Trait

#![allow(unused)]
fn main() {
impl Widget for SpellTimerWidget {
    fn render(&self, frame: &mut Frame, area: Rect) {
        // Create border
        let block = Block::default()
            .title("Spells")
            .borders(Borders::ALL);

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

        // Render spell list
        let items: Vec<ListItem> = self.spells
            .iter()
            .take(self.config.max_display)
            .map(|spell| {
                let text = if self.config.show_duration {
                    format!("{}: {}s", spell.name, spell.duration)
                } else {
                    spell.name.clone()
                };
                ListItem::new(text)
            })
            .collect();

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

    fn name(&self) -> &str {
        &self.config.name
    }

    fn can_focus(&self) -> bool {
        false  // This widget doesn't need focus
    }
}
}

Step 5: Implement Syncable Trait

#![allow(unused)]
fn main() {
impl Syncable for SpellTimerWidget {
    fn sync(&mut self, state: &AppState) -> bool {
        // Check if state has changed
        if state.generation() == self.last_generation {
            return false;  // No changes
        }

        // Update spell list from state
        self.spells = state.active_spells()
            .iter()
            .map(|s| ActiveSpell {
                name: s.name.clone(),
                duration: s.remaining_seconds,
                circle: s.circle,
            })
            .collect();

        // Apply grouping if configured
        if self.config.group_by_circle {
            self.spells.sort_by_key(|s| s.circle);
        }

        self.last_generation = state.generation();
        true  // Widget needs redraw
    }

    fn last_generation(&self) -> u64 {
        self.last_generation
    }
}
}

Step 6: Register Widget Type

In the widget factory (typically src/frontend/tui/mod.rs or dedicated factory):

#![allow(unused)]
fn main() {
pub fn create_widget(config: &WidgetConfig) -> Box<dyn Widget> {
    match config.widget_type {
        WidgetType::Text => Box::new(TextWidget::new(config.into())),
        WidgetType::Progress => Box::new(ProgressWidget::new(config.into())),
        // ... existing types ...
        WidgetType::SpellTimer => Box::new(SpellTimerWidget::new(config.into())),
    }
}
}

Step 7: Add to Module

In src/frontend/tui/mod.rs:

#![allow(unused)]
fn main() {
mod spell_timer;
pub use spell_timer::SpellTimerWidget;
}

Step 8: Document Configuration

Update TOML documentation:

# Example spell timer configuration
[[widgets]]
type = "spell_timer"
name = "spells"
x = 80
y = 0
width = 20
height = 15
show_duration = true
group_by_circle = true
max_display = 10

Widget Best Practices

State Management

#![allow(unused)]
fn main() {
// GOOD: Check generation before updating
fn sync(&mut self, state: &AppState) -> bool {
    if state.generation() == self.last_generation {
        return false;
    }
    // ... update ...
    self.last_generation = state.generation();
    true
}

// BAD: Always update (wasteful)
fn sync(&mut self, state: &AppState) -> bool {
    // ... update ...
    true  // Always redraws
}
}

Efficient Rendering

#![allow(unused)]
fn main() {
// GOOD: Cache computed values
struct MyWidget {
    cached_lines: Vec<Line<'static>>,
    // ...
}

fn sync(&mut self, state: &AppState) -> bool {
    // Rebuild cache only when data changes
    self.cached_lines = self.compute_lines(state);
    true
}

fn render(&self, frame: &mut Frame, area: Rect) {
    // Use cached data
    let paragraph = Paragraph::new(self.cached_lines.clone());
    frame.render_widget(paragraph, area);
}
}

Configuration Validation

#![allow(unused)]
fn main() {
impl SpellTimerConfig {
    pub fn validate(&self) -> Result<(), String> {
        if self.width == 0 || self.height == 0 {
            return Err("Widget must have non-zero dimensions".into());
        }
        if self.max_display == 0 {
            return Err("max_display must be at least 1".into());
        }
        Ok(())
    }
}
}

Error Handling

#![allow(unused)]
fn main() {
fn render(&self, frame: &mut Frame, area: Rect) {
    // Handle edge cases gracefully
    if area.width < 3 || area.height < 3 {
        // Area too small to render
        return;
    }

    if self.spells.is_empty() {
        // Show placeholder
        let text = Paragraph::new("No active spells");
        frame.render_widget(text, area);
        return;
    }

    // Normal rendering...
}
}

Testing Widgets

Unit Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_widget_creation() {
        let config = SpellTimerConfig::default();
        let widget = SpellTimerWidget::new(config);
        assert_eq!(widget.name(), "spell_timer");
    }

    #[test]
    fn test_sync_updates_generation() {
        let mut widget = SpellTimerWidget::new(Default::default());
        let mut state = AppState::new();

        state.set_generation(1);
        assert!(widget.sync(&state));
        assert_eq!(widget.last_generation(), 1);

        // Same generation shouldn't trigger update
        assert!(!widget.sync(&state));
    }
}
}

Visual Testing

Create a test layout that isolates your widget:

# test_layout.toml
[[widgets]]
type = "spell_timer"
name = "test"
x = 0
y = 0
width = 100
height = 100

See Also