Your first app
Build a counter you can change while it runs. You will create a project, add Noir, run the app, press a button, and edit the label without losing the count.
1. Create the project
Create a Dart console application and move into it:
dart create -t console noir_demo
cd noir_demoThe template writes bin/noir_demo.dart. That is the one file you will edit.
2. Add Noir
dart pub add noir3. Write the screen
Replace everything in bin/noir_demo.dart with this complete file:
import 'package:noir/noir.dart';
void main() => runTuiApp(const CounterApp(), enableMouse: true);
class CounterApp extends StatefulWidget {
const CounterApp({super.key});
@override
State<CounterApp> createState() => _CounterAppState();
}
class _CounterAppState extends State<CounterApp> {
int _count = 0;
void _increment() => setState(() => _count++);
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(1),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 1,
children: [
Text('Count: $_count'),
Button(autofocus: true, label: '+ Add one', onPressed: _increment),
],
),
);
}Three parts matter here:
runTuiAppmounts the widget tree and owns the terminal session.enableMouse: truelets the Button receive a click.CounterAppis immutable configuration. Noir can rebuild it as often as it needs to._CounterAppStateowns_count.setStatechanges that value and asks Noir to rebuild this widget.
4. Run it and press the button
Start the entry point through Noir’s development runner:
dart run noir:run bin/noir_demo.dartThe count appears above a focused button. Press Enter or Space, or click the button. Each activation adds one.
Count: 1
+ Add one
If the keyboard does nothing, focus the terminal window. The Button has
autofocus: true, so Noir gives it primary focus when the tree mounts. Press
Ctrl+C to exit; Noir restores the terminal modes it owns.
For a normal launch without the source watcher, run
dart run bin/noir_demo.dart instead.
5. Change the running app
Leave the runner open. Change one word in the Text widget and save the file:
- Text('Count: $_count'),
+ Text('Total: $_count'),The label changes while the current count and the focus stay in place.
Total: 1
+ Add one
The widget declaration changed. The State object that owns the count did not,
so the count survived. That boundary is the whole idea:
State, identity, and ownership explains it in full.
The runner watches Dart files under lib/ and beside the entry point. It asks
the Dart VM to swap the changed sources, then tells Noir to rebuild, lay out,
and paint the retained tree. If an edit does not compile, the last good app
keeps running: fix the source and save again. Reload does not rerun main() or
initState(), and some structural changes need a restart. See
the development runner for its options and
restart boundaries.
What you built
You built a terminal counter that responds to Enter, Space, and mouse clicks.
The widget declares the screen; its State owns the count, and setState
requests a rebuild after each increment. Changing the label through hot reload
keeps that same state and the current count.
Where to go next
- Layout in terminal cells — compose a larger screen.
- Build a task list — learn hooks and reactive state
through a five-lesson exercise. It uses the
noir_signalscompanion. - Examples — runnable apps, including a larger counter with arrow keys and explicit pointer handling.