Skip to content

Coming from Flutter

Fleury is deliberately familiar if you know Flutter: apps are widget trees, local widget state lives in State, build returns widgets, and setState schedules a rebuild. The difference is the surface. Flutter lays out pixels and usually leans on Material/Cupertino; Fleury lays out terminal-style cells and can paint the same tree to a real terminal, a browser embed, or a served browser session.

This is not a Flutter renderer or a Material porting layer. It is the same programming model, tuned for keyboard-first tools, dashboards, agent UIs, and structured terminal/browser surfaces.

Flutter instinctFleury answer
Widget, State, BuildContext, setStateSame model, same names.
runApp(const MyApp())Same name for terminal/native apps.
MaterialApp / WidgetsAppFleuryApp(title:, home:, theme:) is the lightweight app shell.
logical pixelsinteger cells (EdgeInsets.all(1), not 8.0).
Material controlsFleury includes primitives, controls, tables, charts, forms, and agent surfaces in one package.
Flutter web buildmountApp(() => const FleuryApp(title: 'My app', home: MyHomeScreen()), into: host) in a client-side dart2js bundle. (fleury serve previews a native app in a browser during development, on macOS and Linux; it isn’t a web build.)
flutter run, then r / Rfleury run hot reloads when you save a file (macOS and Linux). For a hot restart, press Ctrl+G to open the debug shell, then F5; see Hot reload.

Here is the classic Flutter counter next to its Fleury port. The highlighted lines are the whole diff:

Flutter
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: Counter()));
class Counter extends StatefulWidget {
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Count: $_count'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => setState(() => _count++),
child: const Text('Increment'),
),
],
),
),
);
}
}
Fleury
import 'package:fleury/fleury.dart';
void main(List<String> args) => runApp(
const FleuryApp(title: 'Counter', home: Counter()),
args: args,
mode: const TerminalMode(mouse: true),
);
class Counter extends StatefulWidget {
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Count: $_count'),
const SizedBox(height: 1),
Button(
text: 'Increment',
autofocus: true,
onPressed: () => setState(() => _count++),
),
],
),
);
}
}

The Fleury version runs in your browser. Press Enter, or click Increment. Then change the code, say the label or the step, and press Run:

counter.dart
You write · editable
class Counter extends StatefulWidget {
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Count: $_count'),
const SizedBox(height: 1),
Button(
text: 'Increment',
autofocus: true,
onPressed: () => setState(() => _count++),
),
],
),
);
}
}
Live preview
Live · interactive
click & type to interact ⓘ how this demo runs

What changed:

  • MaterialApp becomes FleuryApp, imported from package:fleury/fleury.dart.
  • There is no Scaffold. A terminal app composes its own chrome, so Center is the screen’s root.
  • SizedBox(height: 16) in pixels becomes height: 1: one row of cells.
  • ElevatedButton(child: Text(...)) becomes Button(text: ...).
  • TerminalMode(mouse: true) enables terminal clicks, as in a generated fleury create app. The entrypoint also forwards command-line args to the host.
  • autofocus: true gives the button keyboard focus, so Enter presses it. Terminal apps are keyboard-first; see Input is keyboard-first.

Everything else, StatefulWidget, State, setState, build, and Column, is the same code.

Most Flutter developers expect one app root and one rendering target. Fleury has the same widget tree, but different host entrypoints:

Use caseImportEntrypoint
Native terminal apppackage:fleury/fleury.dartrunApp(const FleuryApp(title: 'My app', home: MyHomeScreen()))
Client-side browser embedpackage:fleury/fleury_core.dart, package:fleury_web/fleury_web.dartmountApp(() => const FleuryApp(title: 'My app', home: MyHomeScreen()), into: host)
Local browser preview of a native app, during development (macOS and Linux)terminal app importsfleury serve --spawn dart --enable-vm-service=0 run bin/run_app.dart

MyHomeScreen below is the first screen inside the shell. In a project made with fleury create my_app, MyApp already builds FleuryApp: pass const MyApp() directly to runApp, or () => const MyApp() to mountApp, without another shell.

For a browser embed, the entrypoint is a tiny web file:

web/main.dart
import 'package:fleury/fleury_core.dart';
import 'package:fleury_web/fleury_web.dart';
import 'package:web/web.dart' as web;
Future<void> main() async {
final host = web.document.getElementById('app')!;
await mountApp(
() => const FleuryApp(title: 'My app', home: MyHomeScreen()),
into: host,
);
}

Client-side browser bundles can only use web-safe code: dart2js compiles dart:io imports, but that code throws when it runs in the browser. Import package:fleury/fleury_core.dart for shared UI, including every widget. Explicit local filesystem access uses package:fleury/fleury.dart. FileBrowser and FilePicker work with a browser-safe FileSource, such as MemoryFileSource; LogRegion displays log entries supplied by your app. LocalFileSource and code that reads host files or launches processes need a native app. With fleury serve, that work runs on the machine hosting the app; the browser displays the app host’s files and logs.

These transfer with little or no adjustment:

AreaCarries over
Core modelWidget, StatelessWidget, StatefulWidget, State, build, setState, BuildContext
KeysKey, ValueKey, UniqueKey, GlobalKey
LayoutColumn, Row, Expanded, Flexible, Spacer, Stack, Positioned, Padding, Center, Align, Container, ConstrainedBox, AspectRatio, SizedBox, Wrap, IntrinsicWidth, IntrinsicHeight, LayoutBuilder
AsyncFutureBuilder, StreamBuilder, AsyncSnapshot, ConnectionState
Navigation guardsPopScope
Focus and pointer inputFocusNode, Focus, FocusScope, GestureDetector, MouseRegion
Inherited dataTheme.of, MediaQuery.of, DefaultTextStyle
TextText, RichText, TextSpan

The table is intentionally boring: most of the muscle memory is valid.

FlutterFleuryWhy
TextStyleCellStyleA cell has foreground/background color and terminal attributes such as bold, dim, underline, and inverse (reverse-video). It does not have fonts.
BoxConstraintsCellConstraintsConstraints are integer cells; null represents unbounded.
Offset / SizeCellOffset / CellSizeCoordinates and dimensions are whole cells.
ChangeNotifier / notifyListeners()Notifier / notify()An ordinary Dart model announces changes to its consumers.
Navigator.push(context, MaterialPageRoute(...)), pop, popUntil(predicate)context.push(screen), context.pop(result), context.popUntil<HomeScreen>()Routes are widgets: you push the screen itself, with no Route object or route names. popUntil takes the widget type of the screen to stop at.
await tester.pumpWidget(...), find.text(...), findsOneWidgettester.pumpWidget(...), tester.exists(text(...)), tester.button('Save')testWidgets comes from package:fleury_test and runs on a fake clock, so mounting is synchronous: no await. Find controls by role and label, or assert on the rendered cells with tester.renderToString().
AnimatedBuilder / ListenableBuilderNotifierBuilderRebuild from a typed notifier or any Listenable, including an animation.
ValueListenableBuilderNotifierBuilder or context.listenRead a ValueNotifier through the same APIs as other notifiers.
TweenAnimationBuilderAnimationBuilderAnimate a value toward a new target when it changes.
SingleChildScrollViewScrollViewA scrollable viewport around one child.
Shortcuts / Actions / IntentKeyBindings / KeySequence, or AppCommand in a CommandScopeA key sequence maps directly to a callback; no Intent layer. For a named action that buttons, shortcuts, and the command palette share, declare an AppCommand with its shortcuts in a CommandScope or FleuryApp(commands:); see Commands.
InheritedWidget / InheritedNotifierScope<T>One tree-local primitive: the type argument is the key, context.scope<T>() or ScopeBuilder<T> reads it, and a Notifier value notifies readers through the scope. Scope<T>.create lets the scope own the object.

For state, keep setState for a widget’s own fields. Use Scope to share values with descendants, read through ScopeBuilder<T> or context.scope<T>(). Models used by widgets and services extend Notifier; widgets observe them with NotifierBuilder or context.listen(model). ValueNotifier<T> handles notification automatically when its value changes. The State management guide demonstrates all three levels.

EdgeInsets keeps the familiar constructors (all, symmetric, only), but the values are cells:

Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1),
child: Text('two columns, one row'),
)

Fleury layout is integer cell layout. Width is columns; height is rows. A terminal cell is usually taller than it is wide, so a visually square box often uses an AspectRatio around 2.0, not 1.0:

cell_boxes.dart
You write · editable
class CellBoxes extends StatelessWidget {
const CellBoxes({super.key});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final ratio in [1.0, 2.0])
SizedBox(
width: 12,
child: AspectRatio(
aspectRatio: ratio,
child: Container(
border: const BoxBorder(),
alignment: Alignment.center,
child: Text(ratio.toStringAsFixed(1)),
),
),
),
],
);
}
}
Live preview
Live

That sounds small, but it changes what “polish” means. You tune information density, alignment, wrapping, keyboard flow, and semantic structure before you think about pixel-perfect spacing.

ListView.builder requires a finite itemCount and an itemBuilder: (context, index, highlighted) => ... that returns a widget for each index. The extra highlighted argument identifies the current row; Fleury applies the default highlight automatically. Only visible rows are mounted, so it remains suitable for long lists.

By default, selectable: true gives the list a row cursor. Arrow keys move it and report onFocusedItemChanged; Enter or a completed row click calls onSelect. Moving the cursor or scrolling does not select an item. Set selectable: false for scrolling without a row cursor; controls inside rows still handle their own input. Use ScrollView for one child rather than a collection. Lists & scrolling demonstrates these interactions and controllers.

FleuryApp is deliberately smaller than MaterialApp

Section titled “FleuryApp is deliberately smaller than MaterialApp”

You do not port MaterialApp, CupertinoApp, or WidgetsApp wholesale. runApp installs terminal host services such as MediaQuery, focus, pointer routing, the root Overlay, log capture, and scheduling. FleuryApp owns the application concerns: its theme and command/status/extension scopes sit above its Navigator, so pushed routes keep the same app context.

void main(List<String> args) => runApp(
FleuryApp(
title: 'My app',
theme: ThemeData(
colorScheme: const ColorScheme(primary: RgbColor(0x3D, 0xDC, 0x97)),
),
home: const MyHomeScreen(),
),
args: args,
mode: const TerminalMode(mouse: true),
);

title is required, but it only labels the app’s node in the semantic tree that tests and agents read. It doesn’t set a terminal or browser window title.

Small one-screen tools can still pass a bare widget to runApp. For a custom navigation topology, use FleuryApp(child: ...) and place explicit Navigator widgets in that shell. child does not create an implicit route stack.

Flutter’s Shortcuts / Actions stack is intentionally simpler in Fleury: a key sequence maps straight to a callback. Type in the field, then press Ctrl+S or Esc. The bindings sit above the focused field, so its keys reach them, and their labels feed the hint bar:

editor_shortcuts.dart
You write · editable
class EditorShortcuts extends StatefulWidget {
const EditorShortcuts({super.key});
@override
State<EditorShortcuts> createState() => _EditorShortcutsState();
}
class _EditorShortcutsState extends State<EditorShortcuts> {
String _status = 'Type, then press Ctrl+S or Esc';
void save() => setState(() => _status = 'Saved');
void cancel() => setState(() => _status = 'Changes discarded');
@override
Widget build(BuildContext context) {
return KeyBindings(
bindings: [
KeyBinding(KeySequence.ctrl.s, label: 'Save', onTrigger: (_) => save()),
KeyBinding(
KeySequence.escape,
label: 'Cancel',
onTrigger: (_) => cancel(),
),
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const TextInput(autofocus: true, placeholder: 'Notes'),
const SizedBox(height: 1),
Text(_status),
const Spacer(),
const KeyHintBar(),
],
),
);
}
}
Live preview
Live · interactive
click & type to interact ⓘ how this demo runs

Pointer input uses GestureDetector and MouseRegion. Native clicks need TerminalMode(mouse: true); hover needs TerminalMode(mouseMotion: true), which also enables clicks. Browser hosts already deliver pointer motion. Keep every important workflow reachable from the keyboard too.

Flutter’s Animation<T> is usually a read-only listenable driven by an AnimationController. Fleury’s Animation<T> is the mutable value you retarget:

final fill = Animation(0.0);
fill.to(0.8, spring: Spring.snappy);
fill.loop(between: (0.3, 1.0));

For the common “animate when this state value changes” case, use AnimationBuilder, the counterpart of TweenAnimationBuilder. Press Enter to flip selected and watch the value ease toward its new target:

selection_meter.dart
You write · editable
class SelectionMeter extends StatefulWidget {
const SelectionMeter({super.key});
@override
State<SelectionMeter> createState() => _SelectionMeterState();
}
class _SelectionMeterState extends State<SelectionMeter> {
bool selected = false;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Button(
text: selected ? 'Deselect' : 'Select',
autofocus: true,
onPressed: () => setState(() => selected = !selected),
),
const SizedBox(height: 1),
AnimationBuilder<double>(
selected ? 1.0 : 0.0,
builder: (context, t, child) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('selected: ${t.toStringAsFixed(2)}'),
SizedBox(width: 30, child: ProgressBar(value: t)),
],
),
),
],
);
}
}
Live preview
Live · interactive
click & type to interact ⓘ how this demo runs

For entrance/exit effects, use Animate or AnimatedVisibility:

Text('Saved').animate().fadeIn().slideIn();
AnimatedVisibility(
visible: open,
enter: Effects.expand(),
child: Panel(title: 'Details', child: details),
);

With FleuryApp(home: ...) there is no MaterialPageRoute and no named-route table. Push the widget you want to show, pop with an optional result, or pop back to a screen by its type:

routes.dart
You write · editable
/// The app's root navigator. `FleuryApp(home: HomeScreen())` creates one for
/// you; this demo creates it directly.
class ProjectsApp extends StatelessWidget {
const ProjectsApp({super.key});
@override
Widget build(BuildContext context) =>
Navigator(transition: RouteTransition.none, home: const HomeScreen());
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Projects'),
const SizedBox(height: 1),
for (final id in ['atlas', 'borealis'])
Button(
text: 'Open $id',
autofocus: id == 'atlas',
onPressed: () => context.push<void>(DetailScreen(id: id)),
),
],
);
}
}
class DetailScreen extends StatelessWidget {
const DetailScreen({super.key, required this.id});
final String id;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Project $id'),
const SizedBox(height: 1),
Button(
text: 'Open settings',
autofocus: true,
onPressed: () => context.push<void>(DetailScreen(id: '$id/settings')),
),
Button(text: 'Back', onPressed: () => context.pop()),
Button(
text: 'All projects',
onPressed: () => context.popUntil<HomeScreen>(),
),
],
);
}
}
Live preview
Live · interactive
click & type to interact ⓘ how this demo runs

Use PopScope for the same kind of “can this route close?” guard you would use in Flutter.

FlutterUse instead
InkWell / ripplesGestureDetector, MouseRegion, focus styles, and key hints.
Scaffold, AppBar, Material layout chromeCompose Fleury widgets directly; terminal apps usually want denser app-specific chrome.
CustomScrollView / slivers / GridViewListView, ListView.builder, ScrollView, Wrap, or purpose-built table/tree widgets.
Plain BuilderA small StatelessWidget.
FittedBox / FractionallySizedBox / OverflowBoxLayoutBuilder, ConstrainedBox, explicit cell sizing, and wrapping/clipping behavior.
Hero / route-shared element transitionsAnimatedVisibility, route transitions, or simpler terminal-native motion.
  1. Start with the app’s state and widget structure; most StatefulWidget / setState code ports directly.
  2. Replace MaterialApp with FleuryApp(title:, home:, theme:); keep runApp focused on the terminal host.
  3. Translate dimensions from pixels to cells. Remove double spacing habits.
  4. Replace Material controls with Fleury widgets or focused app-specific widgets.
  5. Add keyboard paths first, then pointer affordances.
  6. Decide the host: terminal runApp, or a static browser bundle with mountApp. fleury serve previews a native app in a browser during development on macOS and Linux; it isn’t a way to host one.

The fastest way in is the tutorial, then Widgets & state and App entry points.