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 Browsers

Guide to creating new browser (popup window) types for Two-Face.

Overview

Browsers are popup windows that provide extended functionality:

  • Configuration editors
  • Search interfaces
  • Help dialogs
  • Selection menus

They overlay the main interface temporarily and capture input focus.

Browser Architecture

Browser Lifecycle

Trigger → Creation → Render → Input Loop → Result → Cleanup
   │          │         │          │          │         │
   │          │         │          │          │         └─ Restore main UI
   │          │         │          │          └─ Return data or action
   │          │         │          └─ Handle keys until dismiss
   │          │         └─ Draw overlay on screen
   │          └─ Instantiate browser with context
   └─ Keybind or menu action

Key Components

#![allow(unused)]
fn main() {
// Browser trait defines popup behavior
pub trait Browser {
    fn render(&self, frame: &mut Frame, area: Rect);
    fn handle_input(&mut self, key: KeyEvent) -> BrowserResult;
    fn title(&self) -> &str;
}

// Result of browser interaction
pub enum BrowserResult {
    Continue,           // Keep browser open
    Close,              // Close without action
    Action(BrowserAction),  // Close with action
}

pub enum BrowserAction {
    SelectItem(String),
    UpdateConfig(ConfigChange),
    ExecuteCommand(String),
    // ...
}
}

Step-by-Step: New Browser

Let’s create a “HighlightEditor” browser for editing highlight patterns.

Step 1: Define Browser Structure

Create src/frontend/tui/highlight_editor.rs:

#![allow(unused)]
fn main() {
use ratatui::prelude::*;
use ratatui::widgets::*;

pub struct HighlightEditor {
    highlights: Vec<HighlightEntry>,
    selected: usize,
    editing: Option<EditField>,
    scroll_offset: usize,
}

struct HighlightEntry {
    pattern: String,
    foreground: String,
    background: Option<String>,
    enabled: bool,
}

enum EditField {
    Pattern,
    Foreground,
    Background,
}

impl HighlightEditor {
    pub fn new(highlights: Vec<HighlightConfig>) -> Self {
        let entries = highlights.into_iter()
            .map(|h| HighlightEntry {
                pattern: h.pattern,
                foreground: h.fg,
                background: h.bg,
                enabled: h.enabled,
            })
            .collect();

        Self {
            highlights: entries,
            selected: 0,
            editing: None,
            scroll_offset: 0,
        }
    }
}
}

Step 2: Implement Browser Trait

#![allow(unused)]
fn main() {
impl Browser for HighlightEditor {
    fn title(&self) -> &str {
        "Highlight Editor"
    }

    fn render(&self, frame: &mut Frame, area: Rect) {
        // Calculate centered popup area
        let popup_area = centered_rect(80, 80, area);

        // Clear background
        frame.render_widget(Clear, popup_area);

        // Draw border
        let block = Block::default()
            .title(self.title())
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Color::Cyan));

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

        // Split into list and detail areas
        let chunks = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([
                Constraint::Percentage(50),
                Constraint::Percentage(50),
            ])
            .split(inner);

        // Render highlight list
        self.render_list(frame, chunks[0]);

        // Render selected highlight details
        self.render_details(frame, chunks[1]);

        // Render help bar at bottom
        self.render_help(frame, popup_area);
    }

    fn handle_input(&mut self, key: KeyEvent) -> BrowserResult {
        match key.code {
            KeyCode::Esc => {
                if self.editing.is_some() {
                    self.editing = None;
                    BrowserResult::Continue
                } else {
                    BrowserResult::Close
                }
            }
            KeyCode::Enter => {
                if self.editing.is_some() {
                    self.commit_edit();
                    self.editing = None;
                } else {
                    self.start_edit();
                }
                BrowserResult::Continue
            }
            KeyCode::Up => {
                self.move_selection(-1);
                BrowserResult::Continue
            }
            KeyCode::Down => {
                self.move_selection(1);
                BrowserResult::Continue
            }
            KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                BrowserResult::Action(BrowserAction::UpdateConfig(
                    self.build_config_change()
                ))
            }
            KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.add_new_highlight();
                BrowserResult::Continue
            }
            KeyCode::Delete => {
                self.delete_selected();
                BrowserResult::Continue
            }
            _ => {
                if self.editing.is_some() {
                    self.handle_edit_key(key);
                }
                BrowserResult::Continue
            }
        }
    }
}
}

Step 3: Implement Helper Methods

#![allow(unused)]
fn main() {
impl HighlightEditor {
    fn render_list(&self, frame: &mut Frame, area: Rect) {
        let items: Vec<ListItem> = self.highlights
            .iter()
            .enumerate()
            .skip(self.scroll_offset)
            .map(|(i, h)| {
                let style = if i == self.selected {
                    Style::default().bg(Color::DarkGray)
                } else {
                    Style::default()
                };

                let enabled = if h.enabled { "✓" } else { " " };
                let text = format!("[{}] {}", enabled, h.pattern);
                ListItem::new(text).style(style)
            })
            .collect();

        let list = List::new(items)
            .block(Block::default().title("Patterns").borders(Borders::ALL));

        frame.render_widget(list, area);
    }

    fn render_details(&self, frame: &mut Frame, area: Rect) {
        if let Some(highlight) = self.highlights.get(self.selected) {
            let details = vec![
                Line::from(vec![
                    Span::raw("Pattern: "),
                    Span::styled(&highlight.pattern, Style::default().fg(Color::Yellow)),
                ]),
                Line::from(vec![
                    Span::raw("FG: "),
                    Span::styled(&highlight.foreground, Style::default().fg(Color::Green)),
                ]),
                Line::from(vec![
                    Span::raw("BG: "),
                    Span::raw(highlight.background.as_deref().unwrap_or("none")),
                ]),
            ];

            let paragraph = Paragraph::new(details)
                .block(Block::default().title("Details").borders(Borders::ALL));

            frame.render_widget(paragraph, area);
        }
    }

    fn render_help(&self, frame: &mut Frame, area: Rect) {
        let help = " Enter: Edit | Ctrl+S: Save | Ctrl+N: New | Del: Delete | Esc: Close ";
        let help_area = Rect {
            x: area.x,
            y: area.y + area.height - 1,
            width: area.width,
            height: 1,
        };
        let help_text = Paragraph::new(help)
            .style(Style::default().bg(Color::DarkGray));
        frame.render_widget(help_text, help_area);
    }

    fn move_selection(&mut self, delta: i32) {
        let new_idx = (self.selected as i32 + delta)
            .max(0)
            .min(self.highlights.len() as i32 - 1) as usize;
        self.selected = new_idx;
    }

    fn start_edit(&mut self) {
        self.editing = Some(EditField::Pattern);
    }

    fn commit_edit(&mut self) {
        // Apply edit changes
    }

    fn add_new_highlight(&mut self) {
        self.highlights.push(HighlightEntry {
            pattern: "new_pattern".into(),
            foreground: "white".into(),
            background: None,
            enabled: true,
        });
        self.selected = self.highlights.len() - 1;
    }

    fn delete_selected(&mut self) {
        if !self.highlights.is_empty() {
            self.highlights.remove(self.selected);
            if self.selected >= self.highlights.len() && self.selected > 0 {
                self.selected -= 1;
            }
        }
    }

    fn build_config_change(&self) -> ConfigChange {
        // Convert entries back to config format
        ConfigChange::Highlights(
            self.highlights.iter()
                .map(|h| HighlightConfig {
                    pattern: h.pattern.clone(),
                    fg: h.foreground.clone(),
                    bg: h.background.clone(),
                    enabled: h.enabled,
                })
                .collect()
        )
    }
}

// Helper function for centered popups
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
    let popup_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage((100 - percent_y) / 2),
            Constraint::Percentage(percent_y),
            Constraint::Percentage((100 - percent_y) / 2),
        ])
        .split(area);

    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage((100 - percent_x) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(popup_layout[1])[1]
}
}

Step 4: Register Browser

Add to browser registry:

#![allow(unused)]
fn main() {
pub enum BrowserType {
    Search,
    Help,
    LayoutEditor,
    HighlightEditor,  // New browser
}

pub fn open_browser(browser_type: BrowserType, context: BrowserContext) -> Box<dyn Browser> {
    match browser_type {
        BrowserType::Search => Box::new(SearchBrowser::new(context)),
        BrowserType::Help => Box::new(HelpBrowser::new()),
        BrowserType::LayoutEditor => Box::new(LayoutEditor::new(context)),
        BrowserType::HighlightEditor => Box::new(HighlightEditor::new(context.highlights)),
    }
}
}

Step 5: Add Keybind

Allow users to open the browser:

#![allow(unused)]
fn main() {
// In keybind action handling
KeybindAction::OpenHighlightEditor => {
    self.active_browser = Some(open_browser(
        BrowserType::HighlightEditor,
        self.build_browser_context()
    ));
}
}

Browser Best Practices

Input Handling

#![allow(unused)]
fn main() {
// Always allow Escape to close
fn handle_input(&mut self, key: KeyEvent) -> BrowserResult {
    match key.code {
        KeyCode::Esc => BrowserResult::Close,
        // ... other handling
    }
}

// Provide keyboard help
fn render_help(&self) {
    // Always show available key commands
}
}

State Management

#![allow(unused)]
fn main() {
// Keep original data for cancel
struct MyBrowser {
    original: Vec<Item>,  // For reverting
    modified: Vec<Item>,  // Working copy
}

fn handle_input(&mut self, key: KeyEvent) -> BrowserResult {
    match key.code {
        KeyCode::Esc => {
            // Discard changes
            BrowserResult::Close
        }
        KeyCode::Char('s') if ctrl => {
            // Save changes
            BrowserResult::Action(self.build_action())
        }
    }
}
}

Visual Feedback

#![allow(unused)]
fn main() {
// Indicate edit mode
fn render(&self, frame: &mut Frame, area: Rect) {
    let title = if self.editing.is_some() {
        format!("{} [EDITING]", self.title())
    } else {
        self.title().to_string()
    };
    // ...
}

// Highlight modified fields
fn render_field(&self, value: &str, modified: bool) -> Span {
    let style = if modified {
        Style::default().fg(Color::Yellow)  // Modified indicator
    } else {
        Style::default()
    };
    Span::styled(value, style)
}
}

Testing Browsers

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

    #[test]
    fn test_browser_escape_closes() {
        let mut browser = HighlightEditor::new(vec![]);
        let result = browser.handle_input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
        assert!(matches!(result, BrowserResult::Close));
    }

    #[test]
    fn test_selection_movement() {
        let mut browser = HighlightEditor::new(vec![
            HighlightConfig::default(),
            HighlightConfig::default(),
        ]);
        assert_eq!(browser.selected, 0);

        browser.handle_input(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
        assert_eq!(browser.selected, 1);

        browser.handle_input(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
        assert_eq!(browser.selected, 0);
    }
}
}

See Also