promkit

repository·main·Indexed 19 days ago

https://github.com/ynqa/promkit

A Rust toolkit for building interactive terminal user interfaces (TUIs). It features a decoupled architecture separating the prompt lifecycle runtime, application event policies, widget state management (promkit-widgets), and viewport-aware rendering (promkit-core). It includes a derive macro for generating interactive forms from structs and supports various widgets for data visualization (JSON, YAML, CSV) and user input.

Tokens
21.4K
Snippets
73
Records
85
Agent score
66%

What's inside promkit

  1. How widgets work in promkit

    main

    Widgets in promkit-widgets are responsible for managing state and projecting that state into renderable content (styled graphemes and layout hints).

    Crucially, widgets do not own event loops or key bindings. Instead:

    • Input and focus behavior is defined by your application's Prompt implementations.
    • Terminal layout and drawing is handled by promkit-core.

    This separation allows widgets to remain reusable state machines that focus purely on UI projection.

  2. Understand the promkit architecture and responsibility boundaries

    main

    Since v0.14.0, promkit follows a decoupled architecture where orchestration is owned by the application rather than the library. This prevents key-binding conflicts and allows for complex application-specific logic (like focus management or validation) that cannot be generalized into presets.

    The architecture is divided into four distinct responsibilities:

    1. Prompt Lifecycle Runtime (promkit): Manages the lifecycle via initialize -> evaluate -> finalize and drives input events from a singleton EVENT_STREAM. TerminalSession handles terminal setup/teardown.
    2. Application Event Policy: The developer (the application) implements the Prompt trait. This is where you define key bindings, focus transitions, validation flows, and quit conditions.
    3. State Management and UI Materialization (promkit-widgets): Widgets implement the Widget trait. They are responsible for converting state into CreatedGraphemes (styled content, layout hints, and logical cursor positions). Widgets do not handle events.
    4. Rendering (promkit-core): The Renderer<K> manages ordered chunks of graphemes. RendererLayout<K> handles terminal-size-dependent wrapping, pane allocation, and viewport clipping. Terminal::draw_rows performs the actual terminal I/O.

    By separating these, promkit-widgets provides reusable state-to-view logic, while the application maintains full control over how those widgets interact.

  3. Core concepts of promkit

    main

    promkit is designed around several key architectural principles:

    • Application-owned composition: Instead of a rigid framework, you implement the Prompt lifecycle. This allows your application to own event policies like key bindings, focus, validation, and background work, combining only the specific widget states required.
    • Optional runtime and terminal lifecycle: You can use an asynchronous prompt runtime or independently manage terminal states (raw mode, alternate screen, cursor visibility, mouse capture) using TerminalSession.
    • Viewport-aware rendering: The promkit-core crate handles complex UI tasks like text wrapping/truncation, vertical pane allocation, cursor scrolling, clipping, resizing, and screen-to-widget hit testing.
    • Efficient large-content projection: For heavy data formats like JSON, YAML, and CSV, widgets project only the visible terminal viewport to maintain performance during redraws.
    • Feature-gated architecture: Modules are gated by Cargo features. You should only enable the runtime, terminal session, capabilities, and specific widgets your application actually uses.
  4. How the Prompt lifecycle and event loop work

    main

    The Prompt::run method provides a standard lifecycle for driving a terminal UI. The loop observes events from a singleton EVENT_STREAM, passes them to the application's evaluate implementation, and continues until a Signal::Quit is returned or an error occurs.

    Lifecycle Stages:

    1. initialize(): Setup phase.
    2. evaluate(&event): The core loop where events are processed and state is updated.
    3. finalize(): Teardown phase.

    Data Flow Pattern: To update the UI, an application typically follows this flow within the evaluate loop:

    1. Update widget states based on the event.
    2. Call Widget::create_graphemes to get new content.
    3. Call Renderer::update to modify the renderer's chunks.
    4. Call Renderer::render to perform layout and drawing.

    Note: In the current Prompt::run implementation, resize events are skipped in the main loop and should be handled via other mechanisms (like the terminal session or specific resize logic) to ensure stability.

    self.initialize().await?;
    
    while let Some(event) = EVENT_STREAM.lock().await.next().await {
        match event {
            Ok(event) => {
                if event.is_resize() {
                    continue;
                }
    
                if self.evaluate(&event).await? == Signal::Quit {
                    break;
                }
            }
            Err(_) => break,
        }
    }
    
    self.finalize()
  5. Generate interactive forms with #[derive(Promkit)]

    main

    Use the #[derive(Promkit)] macro from promkit-derive to automatically generate interactive forms for your structs. When you call the generated .build() method on the struct instance, promkit will display an interactive form in the terminal, collect user input, and populate the struct fields with type-safe values.

    Supported types include basic types (e.g., String, usize, i32) and Option<T> types (which result in None if the input is invalid).

    use promkit_derive::Promkit;
    
    #[derive(Default, Debug, Promkit)]
    struct Profile {
        name: String,
        age: usize,
    }
    
    fn main() -> Result<(), Box<dyn std::error::Error>> {
        let mut profile = Profile::default();
        // .build() triggers the interactive terminal form
        profile.build()?;
        dbg!(profile);
        Ok(())
    }
  6. Configure promkit features for your application

    main

    promkit uses Cargo features to allow applications to select only the capabilities and widgets they need. The runtime is independent of the widget set, so you can mix and match features to keep your binary lean.

    Example configuration in Cargo.toml:

    promkit = { version = "0.14.0", features = [
      "runtime",
      "validate",
      "prefixsearch",
      "text",
      "texteditor",
    ] }
  7. Install promkit

    main

    Add promkit to your Cargo.toml dependencies. Note that no features are enabled by default. You must explicitly enable the features you need, such as runtime for the asynchronous prompt lifecycle or texteditor for text editing capabilities.

    [dependencies]
    promkit = { version = "0.14.0", features = ["runtime", "texteditor"] }
  8. Run Renderer layout benchmarks in promkit-core

    main

    You can measure the performance of the Renderer layout independently of terminal size queries and terminal I/O using Criterion benchmarks. This benchmark covers content size, wrapping and truncation, terminal width, pane count, and viewport movement. Note that each iteration includes the cost of cloning renderer inputs and matching the content snapshot paid by Renderer::render.

    cargo bench -p promkit-core --bench renderer_layout
  9. Install promkit-widgets

    main

    Widgets are opt-in Cargo features and are not enabled by default. You can enable them in two ways:

    1. Via the main promkit crate: If you are using the promkit runtime, enable specific widgets through the features array in your Cargo.toml.
    2. Directly via promkit-widgets: If you only need the widget states without the full runtime, add promkit-widgets as a dependency.

    Note that promkit re-exports this crate as promkit::widgets, while promkit-widgets re-exports promkit-core as promkit_widgets::core.

    # Option 1: Using the main promkit crate
    [dependencies]
    promkit = { version = "0.14.0", features = ["runtime", "texteditor"] }
    
    # Option 2: Using promkit-widgets directly
    [dependencies]
    promkit-widgets = { version = "0.7", features = ["json", "yaml"] }
  10. Use the promkit-widgets library

    main

    The promkit-widgets crate provides a collection of interactive terminal UI components (widgets) for building CLI applications. It re-exports promkit_core as core and provides various specialized modules depending on the enabled Cargo features.

    To use specific widgets, you must enable the corresponding feature in your Cargo.toml.

    Available widget modules by feature:

    • checkbox: Checkbox selection.
    • listbox: List selection.
    • prefix_search: Prefix-based search.
    • table: Data tables.
    • text: Text input.
    • status: Status indicators.
    • texteditor: Text editing.
    • spinner: Loading spinners.
    • structured: Structured data viewing (JSON, YAML, or Tree formats).
    # Example Cargo.toml configuration
    [dependencies]
    promkit-widgets = {
        version = "0.14.0",
        features = ["checkbox", "listbox", "spinner", "json"]
    }
  11. Use the TextEditor widget for text manipulation

    main

    The TextEditor struct provides a high-level API for managing text in a terminal environment, supporting basic editing operations like insertion, deletion, overwriting, and cursor movement. It handles complex terminal concerns such as grapheme clusters and display widths (columns) automatically.

    Key Capabilities

    • Editing: Insert characters, newlines, or overwrite existing text.
    • Navigation: Move the cursor by index, by logical row/column, or to the head/tail of the text/lines.
    • Deletion: Erase characters before or after the cursor, or erase to specific boundaries (like word breaks).
    • Masking: Mask all characters except the cursor with a specific character (useful for password fields).
    • Logical Positioning: Translate between absolute grapheme indices and logical TextPosition (row and column) based on terminal display width.
    // Initialize a new editor with text
    let mut editor = TextEditor::new("Hello World");
    
    // Insert text
    editor.insert('!');
    
    // Move cursor
    editor.move_to_tail();
    
    editor.move_up();
  12. Manage terminal lifecycle with TerminalSession

    main

    TerminalSession is used to manage terminal states (like raw mode, alternate screen, cursor visibility, and mouse capture) for interactive applications.

    When you create a session using TerminalSession::try_new, it applies the requested modes. The session is designed to be RAII-compliant: when the TerminalSession instance is dropped, it automatically attempts to restore the terminal to its original state. This restoration is also triggered during panic unwinding to prevent leaving the user's terminal in a broken state.

    Key behaviors:

    • Automatic Restoration: Dropping the session restores all applied modes.
    • Error Resilience: If setup fails halfway through, TerminalSession attempts to roll back all previously applied modes before returning the error.
    • Manual Restoration: You can call .restore() manually to return the terminal to its original state.
    • Panic Safety: Restoration is attempted even if the application panics.
    use promkit::{TerminalModes, TerminalSession};
    
    fn run() -> std::io::Result<()> {
        // Define the modes you want to enable
        let modes = TerminalModes::RAW_MODE
            | TerminalModes::HIDDEN_CURSOR
            | TerminalModes::MOUSE_CAPTURE;
    
        // Start the session. The session will automatically restore the terminal when dropped.
        let _session = TerminalSession::try_new(modes)?;
    
        // Run your application's event loop here...
    
        Ok(())
    }