deno_tui

repository·main·Indexed 43 days ago

https://github.com/im-beast/deno_tui

A lightweight, dependency-free Deno module for creating reactive Terminal User Interfaces (TUIs). It features a component-based system with built-in reactivity via Signal and Computed, support for mouse and keyboard interaction, and specialized components like TextBox.

Tokens
7.1K
Snippets
26
Records
35
Agent score
55%

What's inside deno_tui

  1. Use Reactivity with Signal and Computed

    main

    Tui uses a reactive system to automatically update the UI when data changes.

    • Signal: A wrapper for a value that notifies listeners when it changes. Use .value to read or write the data.
    • Computed: A reactive value that derives its state from other signals. When the underlying signals change, the Computed value updates automatically.

    When accessing a signal's value outside of a reactive context (like inside a standard event callback), use the .peek() method on the component's property to get the current value without creating a dependency.

    const number = new Signal(0);
    
    const button = new Button({
      parent: tui,
      label: {
        text: new Computed(() => number.value.toString()),
      },
      // ...
    });
    
    // Updating the signal triggers UI updates
    button.state.when("active", () => {
      ++number.value;
    });
  2. Quickstart: Create a basic TUI application

    main

    To build a TUI with deno_tui, you need to initialize a Tui instance, enable input handlers, add components, and finally call .run().

    Note for Windows users: If unicode characters do not display correctly, run chcp 65001 in your console to enable UTF-8 encoding.

    Follow these steps:

    1. Initialize Tui: Create a new Tui instance with a style (using a styling module like crayon) and a refreshRate (e.g., 1000 / 60 for 60FPS).
    2. Enable Interaction: Call handleInput(tui), handleMouseControls(tui), and handleKeyboardControls(tui) to allow user input.
    3. Add Components: Instantiate components (like Button) by passing a configuration object that includes a parent (the Tui instance) and layout properties.
    4. Run: Execute tui.run() to start the interface.
    import { crayon } from "https://deno.land/x/crayon@$MODULE_VERSION/mod.ts";
    import { Canvas, Tui, handleInput, handleKeyboardControls, handleMouseControls, Button, Signal, Computed } from "https://deno.land/x/tui@$MODULE_VERSION/mod.ts";
    
    // 1. Create Tui instance
    const tui = new Tui({
      style: crayon.bgBlack,
      refreshRate: 1000 / 60,
    });
    
    // 2. Enable interaction
    handleInput(tui);
    handleMouseControls(tui);
    handleKeyboardControls(tui);
    
    // 3. Add components
    const number = new Signal(0);
    const button = new Button({
      parent: tui,
      zIndex: 0,
      label: {
        text: new Computed(() => number.value.toString()),
      },
      theme: {
        base: crayon.bgRed,
        focused: crayon.bgLightRed,
        active: crayon.bgYellow,
      },
      rectangle: {
        column: 1,
        row: 1,
        height: 5,
        width: 10,
      },
    });
    
    // 4. Run Tui
    tui.run();
  3. Understand the Dependency and Dependant relationship

    main

    The reactivity system is built on the relationship between Dependency and Dependant interfaces:

    • Dependency: An element (like a root Signal) that provides data. It uses the depend(dependant: Dependant) method to register a consumer. It maintains a Set<Dependant> of all elements relying on it.
    • Dependant: An element that relies on one or more Dependency objects. It must implement dispose() to clear its dependencies and update(cause: Dependency | Dependant) to handle state changes triggered by its dependencies or other dependants.
    export interface Dependency {
      depend(dependant: Dependant): void;
      dependants?: Set<Dependant>;
    }
    
    export interface Dependant {
      dependencies: Set<Dependency>;
      dispose(): void;
      update(cause: Dependency | Dependant): void;
    }
  4. Manage Component visibility and state with Signals

    main

    The Component class uses a reactive signal system to manage its properties. This allows for dynamic updates to the UI without manual re-renders.

    Properties that can be controlled via Signal or SignalOfObject include:

    • visible: Controls visibility. When set to false, the component and all its children are hidden. When set to true, the component is redrawn.
    • rectangle: Controls the component's dimensions and position. Supports deepObserve: true to watch for changes in specific rectangle properties.
    • zIndex: Controls the drawing order.
    • view: Controls the associated View.
    • state: A Signal<ComponentState> (where ComponentState is a key of Theme) that determines which theme style is applied via the style computed signal.
  5. How Signal subscriptions and conditional updates work

    main

    The Signal class provides two primary ways to react to changes:

    1. General Subscriptions (subscribe): A callback is executed every time the signal's value changes.
    2. Conditional Subscriptions (when): A callback is executed only when the signal's value matches a specific conditionValue.

    Example

    const status = new Signal('idle');
    
    // Runs on every change
    status.subscribe((val) => {
      console.log(`Status changed to: ${val}`);
    });
    
    // Runs only when status becomes 'loading'
    status.when('loading', () => {
      console.log('System is now loading...');
    });
    
    status.value = 'loading';
    status.value = 'idle';
  6. Handle Component Events and State

    main

    Components in Tui emit events and have a state machine that you can hook into.

    • State Listeners: Use component.state.when(stateName, callback) to execute logic when a component enters a specific state (e.g., "active").
    • Event Listeners: Use component.on(eventName, callback) to listen for user interactions like "mousePress".

    In mouse events, the callback provides an object containing drag (boolean), movementX, and movementY.

    button.state.when("active", (state) => {
      ++number.value;
    });
    
    button.on("mousePress", ({ drag, movementX, movementY }) => {
      if (!drag) return;
      const rectangle = button.rectangle.peek();
      rectangle.column += movementX;
      rectangle.row += movementY;
    });
  7. Configure Signal deep observation options

    main

    When creating a Signal with an object, you can enable deepObserve to watch for changes within the object's properties. This requires the value to be of type object.

    SignalOptions<T>

    • deepObserve (boolean): If true, enables deep observation. Throws SignalDeepObserveTypeofError if the value is not an object.
    • watchObjectIndex (boolean): Only applicable to objects (not Map or Set).
      • true: Uses a Proxy to watch properties, including new ones added after creation.
      • false: Uses Object.defineProperty to watch only properties that existed at the time of creation.
    • watchMapUpdates (boolean): Only applicable to Map instances.
      • true: Checks if the actual value changed during .set().
      • false: Checks only if the map size changed (default).

    Error Handling

    If you attempt to set deepObserve: true on a primitive type (like a number or string), the constructor will throw a SignalDeepObserveTypeofError with the message: "You can only deeply observe value with typeof 'object'".

    const state = new Signal({ count: 0 }, {
      deepObserve: true,
      watchObjectIndex: true
    });
    
    state.value.count++; // Triggers update
  8. Track component interaction history

    main

    The Component class maintains an Interaction object that tracks the most recent user interaction. This is useful for implementing 'idle' timeouts or focus logic.

    • lastInteraction.time: The timestamp (via Date.now()) of the last interaction.
    • lastInteraction.method: The type of interaction, either `
  9. Handle Component events and interactions

    main
    Components act as EventEmitter instances. They emit standard input events (keyPress, mouseEvent, mousePress, mouseScroll) only when the component's state is set to `
  10. Define a Theme for styling components

    main

    A Theme is an object used to define how text is styled across different component states. Each state is represented by a Style function, which takes a string and returns a string (typically containing ANSI escape codes for terminal styling).

    Required properties in a Theme include:

    • base: The default style.
    • focused: The style applied when a component has focus.
    • active: The style applied when a component is actively being interacted with.
    • disabled: The style applied when a component is disabled.
    import type { Theme, Style } from './theme.ts';
    
    const myTheme: Theme = {
      base: (text) => `\x1b[37m${text}\x1b[0m`,      // White
      focused: (text) => `\x1b[34m${text}\x1b[0m`,   // Blue
      active: (text) => `\x1b[32m${text}\x1b[0m`,    // Green
      disabled: (text) => `\x1b[90m${text}\x1b[0m`, // Grey
    };