fleury 0.1.1
fleury: ^0.1.1 copied to clipboard
A Dart-native terminal UI framework with Flutter ergonomics and terminal-native internals.
Fleury #
Flutter ergonomics, terminal truth.
A Dart-native terminal UI framework that brings Flutter's familiar authoring model — widgets, state, context, keys, constraints, inherited dependencies, hot-reload-friendly rebuilds — to terminal UIs, while implementing the runtime with terminal-native best practices: cell buffers, ANSI diffing, raw-mode lifecycle management, grapheme-aware text, capability detection, and safe input handling.
Quick start #
Install the CLI and create a tested application scaffold:
dart pub global activate fleury
fleury create my_app
cd my_app
Open the directory in VS Code and press F5, or run
dart run bin/run_app.dart from an interactive terminal. For the fastest start
use dart run fleury run (fleury run once activated globally): the same
save-to-reload session, but the app is compiled once instead of twice (see
the hot-reload guide).
The generated VS Code
configuration uses the integrated terminal so input, rendering, breakpoints,
and Dart hot reload share one normal debug session. For unreleased changes, use
the Git installation.
A smaller handwritten application looks like this:
import 'package:fleury/fleury.dart';
void main() => runApp(const CounterApp());
class CounterApp extends StatefulWidget {
const CounterApp({super.key});
@override
State<CounterApp> createState() => _CounterAppState();
}
class _CounterAppState extends State<CounterApp> {
int _count = 0;
@override
Widget build(BuildContext context) {
return KeyBindings(
bindings: [
KeyBinding(.space, onTrigger: (_) => setState(() => _count++)),
],
child: Center(
child: Text('count: $_count (space to increment, Ctrl+C to quit)'),
),
);
}
}
A runnable counterpart lives at example/counter_quickstart.dart and is
covered by test/example/counter_quickstart_test.dart; the test mounts the app
and exercises the space-to-increment behavior.
runApp installs the binding, enters raw mode + the alternate screen,
detects terminal capabilities (color depth, image protocol, multiplexer),
wires input dispatch and the frame scheduler, and renders your widget
tree with a diffing ANSI renderer. Unhandled Ctrl+C exits by default.
For a command-sized UI below the shell prompt, pass
mode: const TerminalMode.inline(rows: 14). Earlier output stays in the main
buffer; Fleury clears its live region on exit so you can print a result.
See inline terminal commands or run
dart run example/inline_picker.dart.
For a small application without companion packages, run
dart run example/core_editor.dart: search a list, edit a value, save with
Ctrl+S, and open a compact delete confirmation. Tab/Shift+Tab traverse the
controls; arrows connect search and the list. Button is part of core.
See sensitive input for masking and lifetime choices.
Button content #
Supply exactly one of text and child. Use text for a plain label and
child for composed content; both keep the same bracketed frame, focus and
hover styling, disabled behavior, and Enter/Space/click activation.
Button(text: 'Copy', onPressed: copy);
Button(
onPressed: copy,
semanticLabel: 'Copy',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('[c] ', style: CellStyle(bold: true)),
Text('Copy'),
],
),
);
semanticLabel names the action independently of decorative content. With
text, it defaults to that text. Without an explicit name, a composed child's
semantics remain available. Keep nested interactive controls outside buttons.
Use appearance: ButtonAppearance.plain for an unframed action. It keeps the
same interaction and accessibility behavior with left-aligned content. Its
hover/focus style covers the allocated bounds; wrap it in
Align(alignment: Alignment.centerLeft, child: ...) when a stretching layout
should keep the hit target at the content width.
What's in the box #
- Framework:
Widget/StatefulWidget/State,BuildContext,Scope, keys + reconciliation,MediaQuery, layout constraints, theRenderObjecttree, and a swappableTerminalDriver. The launch support boundary is a modern UTF-8, xterm-compatible POSIX terminal; the native Windows driver is preview-only until real-Windows acceptance lands.fleury_webrenders the same tree to a browser DOM cell grid — selectable text and a real accessibility tree, not a canvas bitmap. - Layout:
Row/Column/Flex,Stack,Align/Center,Padding,SizedBox,Expanded/Flexible, intrinsic sizing,LayoutBuilder. - Input + focus: key bindings that fire once per physical press, and
held-key state sampled per frame for games (
Keyboard.of— key releases negotiated with the terminal automatically, no flags), focus traversal (Tab / directional), dialog focus traps, mouse + pointer routing, paste handling. - Navigation:
Navigatorwith routes + anOverlayfor modals, menus, tooltips, and toasts. - State:
setStatefor local fields,Scope(value, child: ...)for values shared through the tree, andNotifier.notify()for models used across widgets and services. Consume shared state withScopeBuilder/NotifierBuilderorcontext.scope<T>()/context.listen(model)in build. See the state-management guide. - Animation:
AnimationBuilder+Animation(spring-based, value-driven) andFrameBuilder/FrameTicker(frame-indexed), sharing one per-runtime scheduler. See below. - Hot reload: state-preserving reload wired for VS Code — see the hot-reload guide.
- Widgets: the bundled catalog includes inputs, selects, tables, trees, charts, an image widget with Kitty/iTerm2 progressive enhancement, experimental Sixel, and portable glyph rendering, and more.
Design rationale and the phased delivery plan live in the RFC: Fleury framework RFC 0007.
Testing #
The generated scaffold already includes fleury_test. For an existing app,
add it as a dev dependency using the same hosted, Git, or path source as the
rest of your Fleury packages, then use FleuryTester (the testWidgets
analog) from package:fleury_test/fleury_test.dart:
dev_dependencies:
fleury_test: ^0.1.0
test: ^1.26.3
For a Git checkout, use the matching sources described in Install from Git.
With the counter above saved as example/counter_quickstart.dart, this test
goes in test/counter_quickstart_test.dart:
import 'package:fleury/fleury.dart';
import 'package:fleury_test/fleury_test.dart';
import 'package:test/test.dart';
import '../example/counter_quickstart.dart';
void main() {
testWidgets('space increments the counter', (tester) {
tester.pumpWidget(const CounterApp());
tester.sendKey(const KeyEvent(KeyCode.char(' ')));
tester.pump();
expect(tester.renderToString(), contains('count: 1'));
});
}
For whole-screen regression, assert against a golden:
expect(tester.renderToString(), matchesGolden('counter/initial.txt'));
Missing goldens fail by default. Create or update them with
FLEURY_UPDATE_GOLDENS=1 dart test, then review the diff before committing.
Animation #
The animation system lives in two coordinated lanes — value-driven
springs (AnimationBuilder + Animation, which retarget from the live
value AND velocity so an interruption mid-flight stays smooth instead of
snapping) and discrete frame-indexed primitives (FrameTicker /
FrameBuilder). Both share a single per-runtime TickerScheduler owned by
the TuiBinding that runApp installs at the root; idle apps burn no
scheduler CPU.
See animation infrastructure RFC 0010 for the design rationale.
Ready-made widgets #
Spinner() // braille (default) or ascii
Spinner(label: 'Connecting')
BlinkingCursor() // ~500 ms on/off block cursor
Drop them in anywhere — they're built on FrameBuilder and share
the scheduler with every other animation in the app.
Custom animations #
Most animations are one widget. AnimationBuilder<T> owns an Animation,
retargets whenever value changes, and disposes itself — no
StatefulWidget, no didUpdateWidget, no dispose. The engine is a
spring, so a retarget mid-flight continues from the live value and velocity
instead of restarting:
// A number that springs toward its target whenever `value` changes.
AnimationBuilder<int>(value, builder: (context, v, child) => Text('$v'))
// A panel that springs open/closed — toggle rapidly and it stays smooth.
AnimationBuilder<int>(
open ? 30 : 0,
spring: Spring.snappy,
child: child,
builder: (context, width, child) => SizedBox(width: width, child: child),
)
For chained motion, loops, or an animation you drive imperatively, own an
Animation<T> in your State and append targets and delays directly:
final _x = Animation<int>(28);
// A toast: slide in, delay, slide out. The returned future fires at the end.
await _x
.to(0, spring: Spring.snappy)
.delay(const Duration(seconds: 2))
.to(
28,
curve: Curves.easeIn,
duration: const Duration(milliseconds: 200),
);
// A pulsing dot: ping-pong forever; settles to nothing when off-screen.
final _c = Animation<RgbColor>(const RgbColor(120, 0, 0))
..loop(
between: (const RgbColor(120, 0, 0), const RgbColor(255, 60, 60)),
period: const Duration(milliseconds: 700),
curve: Curves.easeInOut,
);
example/animation_recipes.dart is a runnable gallery of these patterns —
counter, interruptible panel, toast, badge flash, selection highlight,
health color — each mirrored by a widget test in
test/example/animation_recipes_test.dart.
For low-frequency frame-indexed animations (spinners, cursor blink,
typing indicators), FrameBuilder is more direct:
FrameBuilder(
interval: const Duration(milliseconds: 80),
builder: (ctx, frame, elapsed, delta) {
final frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
return Text(frames[frame % frames.length]);
},
)
Animation control #
TickerMode(enabled: false, child: ...)— mute tickers in a subtree (hidden tab, modal background). Internal elapsed-time state continues to advance so re-enabling resumes at the correct value; no replay of missed frames.AnimationPolicy.disabledon the binding — show final animation states immediately in tests and non-interactive hosts.
Examples #
example/animation_showcase.dart— every animated widget demonstrated together.example/chat_demo.dart— aSpinnerintegrated into the message-pane header of the full chat layout.
Color #
rgbColorLerp(a, b, t) interpolates RgbColor channels linearly; feed the
result to an Animation<RgbColor> (or read it inside an
AnimationBuilder<RgbColor>) to glide a color toward a target. Indexed ANSI
colors aren't on a meaningful number line, so animate the RGB value rather
than interpolating palette indices.
Bounded text fields #
Use a controller policy for short names, search terms, or confirmation fields:
// Declare this top-level or static: a const policy can't refer to a local
// function (in build()) or an instance method (on your State).
bool printableAscii(int point) => point >= 32 && point <= 126;
final controller = TextEditingController(
editPolicy: const TextEditPolicy(
maxCodeUnits: 120,
allowCodePoint: printableAscii,
),
onEditRejected: (reason) {
// Show optional feedback without clearing the accepted text.
},
);
final field = TextInput(controller: controller);
The limit counts UTF-16 code units. It applies before normalization to typing, replacement, paste, IME updates/commits, semantic edits, and assignments. Invalid edits are rejected as a whole; text, selection and undo/redo are preserved. Deletion and empty text remain valid; validate required fields on submission. A segmented paste waits for its final marker and becomes one undo step. An interrupted or invalid paste is discarded completely, with no partial prefix. Unconstrained controllers keep their normal incremental paste behavior. This policy does not replace secret byte limits or erasure requirements.