Skip to content

Input & gestures

Fleury routes pointer input through widget bounds and keyboard input through focus. Built-in controls combine both; gesture detectors and mouse regions add pointer behavior to custom widgets. This guide covers press feedback, drag capture, and nested targets, including the keyboard and semantic support custom controls need.

Use CellStyle.interactive to style a button while it is held. The pressed entry applies only while a primary pointer press is held, and Fleury clears it on release or cancellation. Keyboard and semantic activation still work; they run the action at once, without a pressed state.

Interaction
Pressed feedback on a standard button · editable
bool open = false;
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Button(
text: open ? 'Close note' : 'Open note',
onPressed: () => setState(() => open = !open),
autofocus: true,
style: const CellStyle.interactive(
focused: CellStyle(underline: true),
pressed: CellStyle(inverse: true),
),
),
const SizedBox(height: 1),
Container.framed(
padding: const EdgeInsets.all(1),
child: Text(
open
? 'Planning notes\nMeet on Tuesday.\nBring the sketches.'
: 'Preview closed',
),
),
],
);
Hold the button, then drag away to cancel
Live · interactive
click & type to interact ⓘ how this demo runs
Full source
import 'package:fleury/fleury_core.dart';
class FileActions extends StatefulWidget {
const FileActions({super.key});
@override
State<FileActions> createState() => _FileActionsState();
}
class _FileActionsState extends State<FileActions> {
bool open = false;
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Button(
text: open ? 'Close note' : 'Open note',
onPressed: () => setState(() => open = !open),
autofocus: true,
style: const CellStyle.interactive(
focused: CellStyle(underline: true),
pressed: CellStyle(inverse: true),
),
),
const SizedBox(height: 1),
Container.framed(
padding: const EdgeInsets.all(1),
child: Text(
open
? 'Planning notes\nMeet on Tuesday.\nBring the sketches.'
: 'Preview closed',
),
),
],
);
}

For a custom tile, pass your own content with Button(child: …) and remove the brackets with appearance: ButtonAppearance.plain; the pressed style and actions work the same. The button reference also covers onSecondaryPressed for secondary-click actions.

GestureDetector captures a drag until release or cancellation, including movement outside the handle. Apply the reported horizontal delta to the pane’s width.

Translate movement into a width change · editable
bool focused = false;
int leftWidth = 14;
bool dragging = false;
void resize(int delta) => setState(() {
leftWidth = (leftWidth + delta).clamp(8, 24);
});
void finish() => setState(() => dragging = false);
@override
void dispose() {
focus.dispose();
super.dispose();
}
Widget get dividerGesture => MouseRegion(
cursor: MouseCursor.resizeLeftRight,
child: GestureDetector(
onDragStart: (_) => setState(() => dragging = true),
onDragUpdate: (details) => resize(details.delta.col),
onDragEnd: (_) => finish(),
onDragCancel: finish,
child: SizedBox(
width: 1,
child: Text(
'│\n│\n│\n│\n│',
allowSelect: false,
style: CellStyle(inverse: focused, bold: dragging),
),
),
),
);
Drag past the divider, then try the arrow keys
Live · interactive
click & type to interact ⓘ how this demo runs
Full source
import 'package:fleury/fleury_core.dart';
class SplitPane extends StatefulWidget {
const SplitPane({super.key});
@override
State<SplitPane> createState() => _SplitPaneState();
}
class _SplitPaneState extends State<SplitPane> {
final focus = FocusNode();
bool focused = false;
int leftWidth = 14;
bool dragging = false;
void resize(int delta) => setState(() {
leftWidth = (leftWidth + delta).clamp(8, 24);
});
void finish() => setState(() => dragging = false);
@override
void dispose() {
focus.dispose();
super.dispose();
}
Widget get dividerGesture => MouseRegion(
cursor: MouseCursor.resizeLeftRight,
child: GestureDetector(
onDragStart: (_) => setState(() => dragging = true),
onDragUpdate: (details) => resize(details.delta.col),
onDragEnd: (_) => finish(),
onDragCancel: finish,
child: SizedBox(
width: 1,
child: Text(
'│\n│\n│\n│\n│',
allowSelect: false,
style: CellStyle(inverse: focused, bold: dragging),
),
),
),
);
List<KeyBinding> get resizeKeys => [
KeyBinding(
KeySequence.left,
includeRepeats: true,
onTrigger: (_) => resize(-1),
),
KeyBinding(
KeySequence.right,
includeRepeats: true,
onTrigger: (_) => resize(1),
),
];
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 5,
child: Row(
children: [
SizedBox(
width: leftWidth,
child: const Text('Files\n\nnotes.md\nsketches.txt'),
),
KeyBindings(
bindings: resizeKeys,
child: FocusDetector(
onFocusChange: (value) => setState(() => focused = value),
child: Focus(
focusNode: focus,
autofocus: true,
child: Semantics(
role: SemanticRole.slider,
label: 'File pane width',
value: leftWidth,
focused: focused,
state: const SemanticState({
'min': 8,
'max': 24,
'step': 1,
}),
actions: {
SemanticAction.focus,
if (leftWidth < 24) SemanticAction.increment,
if (leftWidth > 8) SemanticAction.decrement,
},
onAction: (action) {
focus.requestFocus();
if (action == SemanticAction.increment) resize(1);
if (action == SemanticAction.decrement) resize(-1);
},
child: dividerGesture,
),
),
),
),
const Expanded(
child: Padding(
padding: EdgeInsets.only(left: 1),
child: Text(
'notes.md\n\nMeet on Tuesday.\nBring the sketches.',
),
),
),
],
),
),
Text(dragging ? 'Resizing…' : 'Width: $leftWidth · ← → resize'),
const SizedBox(height: 1),
Button(
text: 'Reset width',
onPressed: () => setState(() => leftWidth = 14),
),
],
);
}

Use the reported delta, in terminal cells, rather than comparing local positions: resizing moves the handle’s origin. Apply it only in the update callback; the first movement also fires drag start with the same delta.

Gesture detection doesn’t add keyboard or semantic actions. Put the arrow bindings around a focusable handle and call the same resize function to share its width limits. The full source also exposes the width and increment/decrement actions through semantics. See focus management for wiring custom controls into traversal.

Keep press feedback separate from app state

Use the press lifecycle when cancellation needs custom feedback. In Fleury, moving to another terminal cell cancels a tap, even while the pointer remains inside the target.

Press lifecycle
Transient feedback, persistent preview · editable
Widget get pressTarget => GestureDetector(
onTapDown: (_) => setState(() => pressed = true),
onTapUp: (_) => setState(() => pressed = false),
onTapCancel: () => setState(() {
pressed = false;
feedback = 'Cancelled';
}),
onTap: open,
onSecondaryTap: details,
child: fileTile,
);
Open the note, then cancel another press
Live · interactive
click & type to interact ⓘ how this demo runs
Full source
import 'package:fleury/fleury_core.dart';
import 'note_preview.dart';
class PressTile extends StatefulWidget {
const PressTile({super.key});
@override
State<PressTile> createState() => _PressTileState();
}
class _PressTileState extends State<PressTile> {
final focus = FocusNode();
bool focused = false;
bool pressed = false;
String status = 'Ready';
String? feedback;
void open() => setState(() {
status = 'Opened notes.md';
feedback = null;
});
void details() => setState(() {
status = 'notes.md · Markdown · 2 KB';
feedback = null;
});
@override
void dispose() {
focus.dispose();
super.dispose();
}
Widget get pressTarget => GestureDetector(
onTapDown: (_) => setState(() => pressed = true),
onTapUp: (_) => setState(() => pressed = false),
onTapCancel: () => setState(() {
pressed = false;
feedback = 'Cancelled';
}),
onTap: open,
onSecondaryTap: details,
child: fileTile,
);
Widget get fileTile => SizedBox(
width: 31,
child: Padding(
padding: const EdgeInsets.only(left: 2),
child: Text(
'notes.md\nPlanning notes\nOpen preview',
style: CellStyle(
inverse: pressed,
underline: focused,
foreground: pressed ? Colors.yellow : Colors.cyan,
),
),
),
);
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
KeyBindings(
bindings: [
KeyBinding(
KeySequence.enter,
aliases: [KeySequence.space],
onTrigger: (_) => open(),
),
KeyBinding(KeySequence.i, onTrigger: (_) => details()),
],
child: FocusDetector(
onFocusChange: (value) => setState(() => focused = value),
child: Focus(
focusNode: focus,
autofocus: true,
child: Semantics(
role: SemanticRole.button,
label: 'Open notes',
focused: focused,
actions: const {SemanticAction.focus, SemanticAction.activate},
onAction: (action) {
focus.requestFocus();
if (action == SemanticAction.activate) open();
},
child: SelectionArea.disabled(
child: MouseRegion(
cursor: MouseCursor.pointer,
child: pressTarget,
),
),
),
),
),
),
const SizedBox(height: 1),
Button(text: 'Details', onPressed: details),
const SizedBox(height: 1),
NotePreview(status: status, feedback: pressed ? 'Pressed…' : feedback),
const SizedBox(height: 1),
Button(
text: 'Close preview',
onPressed: () => setState(() {
status = 'Ready';
feedback = null;
}),
),
const SizedBox(height: 1),
const Text('Enter: open · I: details', style: CellStyle(dim: true)),
],
);
}
Preview rendering
import 'package:fleury/fleury_core.dart';
/// The workspace preview shared by the button and custom-tile examples.
class NotePreview extends StatelessWidget {
const NotePreview({required this.status, this.feedback, super.key});
final String status;
final String? feedback;
@override
Widget build(BuildContext context) {
final opened = status == 'Opened notes.md';
final inspecting = status.contains('Markdown');
final held = feedback == 'Pressed…';
return SizedBox(
height: 7,
child: Panel(
title: opened
? 'PREVIEW OPEN'
: inspecting
? 'FILE DETAILS'
: 'PREVIEW',
focused: opened || held,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
feedback ?? status,
style: CellStyle(
bold: true,
foreground: feedback != null ? Colors.yellow : Colors.cyan,
),
),
Text(
opened
? 'Planning notes\nMeet on Tuesday.\nBring the sketches.'
: inspecting
? 'Type: Markdown\nSize: 2 KB\nLocation: /notes'
: 'Your note will appear here.\nOpen notes.md to read it.',
),
],
),
),
);
}
}

Keep press feedback separate from the preview state. The cancellation handler clears the held appearance and reports cancellation without closing an already open note.

Hit areas and clipping

A detector’s target is the area laid out for its child. Put padding inside the detector to give a small handle a larger hit area. Clipping limits that area; if the target disappears, the gesture is cancelled.

Wrap a row in MouseRegion when its hover state should include child controls. This adds enter/exit callbacks without intercepting the children’s presses. In a native terminal, hover needs TerminalMode(mouseMotion: true); the bin/run_app.dart that fleury create generates sets only mouse: true, which reports presses, drags, and the wheel but not plain motion. See Enable mouse input in a terminal. Browser demos receive pointer motion automatically.

Interaction
Hover on the parent, action on the button · editable
bool hovered = false;
bool pinned = false;
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MouseRegion(
onEnter: () => setState(() => hovered = true),
onExit: () => setState(() => hovered = false),
child: Container.framed(
width: 26,
padding: const EdgeInsets.symmetric(horizontal: 1),
border: BoxBorder(
cellStyle: CellStyle(foreground: hovered ? Colors.cyan : null),
),
child: Row(
children: [
const Text('notes.md'),
const SizedBox(width: 2),
Button(
text: pinned ? 'Unpin' : 'Pin',
onPressed: () => setState(() => pinned = !pinned),
),
],
),
),
),
const SizedBox(height: 1),
Text(hovered ? 'Inside file row' : 'Outside file row'),
Text(pinned ? 'Pinned to sidebar' : 'Not pinned'),
],
);
Move from the filename to Pin, then outside the row
Live · interactive
click & type to interact ⓘ how this demo runs
Full source
import 'package:fleury/fleury_core.dart';
class NestedRow extends StatefulWidget {
const NestedRow({super.key});
@override
State<NestedRow> createState() => _NestedRowState();
}
class _NestedRowState extends State<NestedRow> {
bool hovered = false;
bool pinned = false;
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MouseRegion(
onEnter: () => setState(() => hovered = true),
onExit: () => setState(() => hovered = false),
child: Container.framed(
width: 26,
padding: const EdgeInsets.symmetric(horizontal: 1),
border: BoxBorder(
cellStyle: CellStyle(foreground: hovered ? Colors.cyan : null),
),
child: Row(
children: [
const Text('notes.md'),
const SizedBox(width: 2),
Button(
text: pinned ? 'Unpin' : 'Pin',
onPressed: () => setState(() => pinned = !pinned),
),
],
),
),
),
const SizedBox(height: 1),
Text(hovered ? 'Inside file row' : 'Outside file row'),
Text(pinned ? 'Pinned to sidebar' : 'Not pinned'),
],
);
}

A press goes to the innermost target that handles taps or drags, and only that target’s onTap or onSecondaryTap fires on release. Wrapping a button in a gesture detector therefore won’t observe its taps or add secondary-click handling; use the button’s own callbacks. A drag that starts on the button goes to the nearest ancestor that handles drags, which also receives onTapDown. Hover remains active on containing mouse regions.

Wheel input can pass to an ancestor at a scroll edge; see nested scrolling for containment.

Keep copied text separate from controls

In a terminal app, runApp makes ordinary text selectable. Use a SelectionArea for a region with its own selection callback or options. Set allowSelect: false on decorative text inside a control, such as a resize handle’s │, so a selection dragged across it doesn’t copy the glyph. Dragging the handle itself resizes: its detector is the nearest target that handles drags, so the selection never sees that drag.

Native terminals require mouse reporting to be enabled at startup. These browser demos already receive pointer events.

runApp(const MyApp(), mode: const TerminalMode(mouseMotion: true));

This enables clicks, drags, scrolling, and hover; mouse: true omits hover. Reporting is opt-in because it takes over the terminal’s native text selection.

Continue with key bindings, forms, or interactive styles.