noir 0.0.1-alpha.1 copy "noir: ^0.0.1-alpha.1" to clipboard
noir: ^0.0.1-alpha.1 copied to clipboard

A Flutter-like reactive terminal UI framework for Dart, powered by OpenTUI.

Noir #

Noir is a Flutter-like reactive terminal UI framework for Dart, powered by OpenTUI. It combines declarative widgets, integer-cell layout, stateful rebuilds, focus and input routing, animation, and bundled native rendering in one package.

Noir is currently a prerelease. APIs and platform guarantees may change before stable 1.0.

  • Build interfaces with StatelessWidget, StatefulWidget, BuildContext, and setState.
  • Compose layouts with Row, Column, Container, Padding, SizedBox, Align, Flexible, and Expanded.
  • Handle text editing, selection, scrolling, keyboard focus, mouse input, and application-wide shortcuts.
  • Drop to supported renderer, buffer, or raw FFI APIs when an application needs more control.

Install #

Install the latest prerelease from pub.dev:

dart pub add noir

When developing against a local checkout, use a path dependency:

dependencies:
  noir:
    path: ../noir

Private Git consumers need access to leoafarias/noir and should pin an exact commit or release tag rather than a moving branch.

Quick Start #

Import package:noir/noir.dart and mount a widget tree with runTuiApp:

import 'package:noir/noir.dart';

void main() => runTuiApp(const HelloApp());

class HelloApp extends StatelessWidget {
  const HelloApp({super.key});

  @override
  Widget build(BuildContext context) => Container(
    color: Color.rgb(0.05, 0.06, 0.1),
    padding: const EdgeInsets.all(2),
    child: const Column(
      spacing: 1,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          'Noir',
          style: TextStyle(
            color: Color.yellow,
            fontWeight: FontWeight.bold,
          ),
        ),
        Text('Flutter-like widgets for terminal apps.'),
        Text('Press Ctrl+C to exit.'),
      ],
    ),
  );
}

The complete version is available in the hello example.

Stateful widgets persist a State object between supported rebuilds. Call setState after changing local state:

import 'package:noir/noir.dart';

void main() {
  final app = runTuiApp(const CounterApp());
  app.enableMouse();
}

class CounterApp extends StatefulWidget {
  const CounterApp({super.key});

  @override
  State<CounterApp> createState() => _CounterAppState();
}

class _CounterAppState extends State<CounterApp> {
  int _count = 0;

  void _incrementCounter() => setState(() => _count++);

  void _decrementCounter() => setState(() => _count--);

  KeyEventResult _handleKey(FocusNode node, KeyEvent event) {
    if (!event.isPress) return KeyEventResult.ignored;
    if (event.logicalKey == LogicalKeyboardKey.arrowUp ||
        event.character == '+' ||
        event.logicalKey == LogicalKeyboardKey.enter ||
        event.logicalKey == LogicalKeyboardKey.space) {
      _incrementCounter();
      return KeyEventResult.handled;
    }
    if (event.logicalKey == LogicalKeyboardKey.arrowDown ||
        event.character == '-') {
      _decrementCounter();
      return KeyEventResult.handled;
    }
    return KeyEventResult.ignored;
  }

  void _handlePointerDown(MouseEvent event) {
    if (event.button == MouseButton.left) _incrementCounter();
  }

  @override
  Widget build(BuildContext context) => Focus(
    autofocus: true,
    onKeyEvent: _handleKey,
    child: Container(
      color: Color.fromHex('#FAFAFA'),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Container(
            height: 3,
            color: Color.fromHex('#1976D2'),
            alignment: Alignment.centerLeft,
            padding: const EdgeInsets.symmetric(horizontal: 2),
            child: const Text(
              'Noir Counter',
              style: TextStyle(fontWeight: FontWeight.bold),
            ),
          ),
          Expanded(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(
                  'You have pushed the button this many times:',
                  style: TextStyle(color: Color.fromHex('#424242')),
                ),
                Text(
                  '$_count',
                  style: TextStyle(color: Color.fromHex('#1976D2')),
                ),
              ],
            ),
          ),
          Row(
            children: [
              const Expanded(
                child: Text(
                  'Up/+ | Down/- | Enter/Space | Ctrl+C',
                  style: TextStyle(color: Color.gray),
                ),
              ),
              PointerListener(
                onPointerDown: _handlePointerDown,
                child: Container(
                  width: 7,
                  height: 3,
                  alignment: Alignment.center,
                  color: Color.fromHex('#1976D2'),
                  child: const Text('+'),
                ),
              ),
            ],
          ),
        ],
      ),
    ),
  );
}

See the counter example for the complete styled version with a solid action button and hot-reload registration.

Application Lifecycle and API Tiers #

runTuiApp mounts the root widget and returns a TuiApp handle synchronously. Keep that handle when you register application-wide input or exit programmatically. onKey, onMouse, and onPaste install app-priority handlers, and each returns an idempotent canceler.

TuiApp.dispose() is idempotent. It cancels every still-owned registration before disposing the mounted app, input modes, and renderer resources. Always dispose the handle before a programmatic process exit. The default POSIX signal handling performs cleanup for SIGINT, SIGTERM, and SIGHUP. In raw input mode, an unconsumed Ctrl+C key follows the same interrupt cleanup path and exits with status 130; an app or focused widget can consume it first to override that default.

headless: true creates no owned terminal renderer and is exposed through TuiApp.isHeadless. Renderer-backed mouse and Kitty keyboard mode controls are unavailable in that mode.

TuiApp.reassemble() rebuilds the whole widget tree and forces a full layout and paint pass without recreating any State, terminal, or native resource. It is the hot-reload hook: call it after a source swap succeeds, or call registerHotReloadExtension(app) once from main() with the handle runTuiApp returned, so a development driver can invoke it over the VM service extension ext.noir.reassemble.

Noir has three supported import tiers:

  • package:noir/noir.dart — ordinary application and widget authoring.
  • package:noir/noir_low_level.dart — advanced hosting, renderer/buffer access, and supported custom rendering.
  • package:noir/noir_ffi.dart — ABI-unstable raw FFI access.

Concrete Element implementations and the recorder/display-list/compositor backend remain framework-owned; they are not supported package surfaces.

Example Apps #

  • Hello — a minimal stateless application.
  • Counter — a Flutter-inspired app bar, centered stateful body, and solid action button controlled by Up/Down, +/-, Enter/Space, or click.
  • Layout basics — core layout and flex usage.
  • Layout demo — alignment, decoration, and richer flex combinations.
  • Inherited state — inherited dependencies and visible rebuild propagation when t switches palettes.
  • Framework primitives — notifier ownership, semantic shortcuts/actions, a GlobalKey, rich text, and localized pointer input in one focused app.
  • Focus form — focus management and text input.
  • Select — keyboard and mouse option selection.
  • Scroll box — clipped keyboard/wheel scrolling and scrollbars.
  • Text area — multiline editing with portable Ctrl+D submission.
  • Widgets tour — the interactive widget set with selection, scrolling, and text submission.
  • Chat demo — scrollback, input, asynchronous state, and animation.
  • Pulse animationAnimationController and ticker-driven updates.
  • Like Reactor — deterministic heart particles, animation-driven morphing, and overlapping keyboard/mouse activation.
  • Bindings validation — interactive advanced renderer/buffer and ABI-unstable FFI validation in a terminal at least 120×40 cells.

The example guide includes the command for every app.

Supported Keyboard and Mouse Input #

Input Result
Printable ASCII / UTF-8 KeyEvent with the typed character
Backspace (BS / DEL) LogicalKeyboardKey.backspace
Tab LogicalKeyboardKey.tab
Enter (CR / LF) LogicalKeyboardKey.enter
Escape LogicalKeyboardKey.escape
Ctrl + letter KeyEvent with KeyModifiers.ctrl
Arrow keys arrowUp, arrowDown, arrowLeft, arrowRight
Home / End home, end
Insert / Delete / PageUp / PageDown insert, delete, pageUp, pageDown
Function keys f1f12
Mouse SGR MouseEvent with type, button, cell position, modifiers, and directional scroll magnitude
Bracketed paste one PasteEvent per block through app.onPaste
xterm modifyOtherKeys modified named and printable keys, including Ctrl+Enter
Kitty keyboard modifiers and press/repeat/release metadata after app.enableKittyKeyboard()

SIGWINCH resizes the terminal buffer and lays out the widget tree again.

Native Libraries #

Noir ships bundled native libraries for supported desktop targets. The build hook selects and SHA-256 verifies the bundled target before exposing it as a Dart native asset. If a library is missing or its checksum mismatches, the package is incomplete or corrupt and the build fails.

Operating system Architectures
macOS x64, arm64
Linux x64, arm64
Windows x64, arm64

The bundled macOS x64 and arm64 libraries require macOS 13.0 or later. For linked macOS applications, Dart must rewrite the dylib install name for a relocatable bundle. The official dylib has no load-command padding, so Noir removes its optional source-version load command from a temporary hook output copy before Dart rewrites and signs that copy. The six tracked release assets remain byte-for-byte identical to the hashes in native_manifest.json.

Run the packaged diagnostic to check bundled native asset resolution without writing terminal controls:

dart run noir:health_check

The diagnostic uses OpenTUI's native testing mode to exercise the bundled native asset and headless buffer/render lifecycle. It does not validate real terminal escape rendering.

Android, iOS, and web are not supported targets. See Third-Party Notices for OpenTUI provenance and license terms.

High-level Unicode cell measurement uses a compact pure-Dart range table derived from the exact uucode revision pinned by OpenTUI v0.5.1 (Unicode 16.0) plus OpenTUI's width overrides. This keeps widget layout out of FFI while matching the pinned native release's code-point and grapheme rules.

The URLs in native_manifest.json record immutable provenance for the currently bundled artifacts. They are not an update instruction or a runtime recovery path.

OPENTUI_LIBRARY_PATH is the exact development override for a compatible custom library. It is not a search path and not a remedy for an incomplete or corrupt package.

Known Limitations #

  • Dart 3.10 supplies a macOS deployment target of 12 to native-asset hooks, while the bundled OpenTUI libraries require macOS 13. Because dart build cli does not expose a deployment-target override, Noir documents macOS 13 as its minimum and allows the normal build to proceed. On macOS 12, the native loader can therefore report the incompatibility at runtime rather than the hook rejecting the build earlier.
  • High-level layout and painting keep multi-code-point graphemes intact and expand intersecting selection ranges to whole grapheme clusters. DirectBufferAccess.getEncodedCellAt() exposes guarded access to native encoded storage; packed grapheme words are not independently decodable Unicode scalars.
  • OpenTUI v0.5.1's native bufferDrawText path mishandles a run whose first grapheme has source-level width zero: it can emit UTF-8 continuation bytes as cells and advance before the following text. Noir keeps the pinned source's correct zero-width layout semantics; leading zero-width graphemes, including ones isolated by a style boundary, can therefore diverge from native paint.
  • Decorated box content can escape a clipped viewport in some overflow cases.
  • Some low-level native operation failures cannot be reported precisely to Dart.
  • On the observed macOS/iTerm path, the pinned alternate-screen lifecycle can return to main-screen row 1/column 1 instead of the launch cursor and overwrite prior shell rows. Callers must still dispose TuiApp so all owned resources and terminal modes are released.
  • Hot reload is bounded by what the Dart VM can swap into a live isolate. TuiApp.reassemble() re-runs build(), layout, and paint bodies only: it never re-runs main() or initState, so changes to those, to a signature held by a frame on the stack, to an enum converted into a class, or to the bundled OpenTUI native library still require a full restart.
  • The current Linux libraries retain absolute build/debug paths. They pass static integrity checks; this is visible upstream artifact metadata rather than a Noir rebuild output.
11
likes
0
points
379
downloads

Publisher

unverified uploader

Weekly Downloads

A Flutter-like reactive terminal UI framework for Dart, powered by OpenTUI.

Repository (GitHub)
View/report issues

Topics

#tui #terminal #cli #widget #ffi

License

unknown (license)

Dependencies

characters, code_assets, crypto, ffi, hooks, meta

More

Packages that depend on noir