ashlarui

Command

A search box over a list of things to do, picked with the keyboard.

Command, as it looks in an app Command, as it looks in an app, dark
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';

import 'ui/command.dart';
import 'ui/theme.dart';

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

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    return Container(
      width: 420,
      clipBehavior: Clip.antiAlias,
      decoration: BoxDecoration(
        color: t.colors.popover,
        borderRadius: BorderRadius.circular(t.radiusLg),
        border: Border.all(color: t.colors.border),
        boxShadow: t.shadowSm,
      ),
      child: Command(
        autofocus: false,
        groups: [
          CommandGroup(heading: 'Suggestions', [
            CommandItem('Calendar', leading: const AshlarIcon(AshlarIconsRoundedStroke.calendar), onSelected: () {}),
            CommandItem(
              'Search emoji',
              leading: const AshlarIcon(AshlarIconsRoundedStroke.faceSlightlySmiling),
              onSelected: () {},
            ),
            CommandItem(
              'Calculator',
              leading: const AshlarIcon(AshlarIconsRoundedStroke.calculator),
              enabled: false,
              onSelected: () {},
            ),
          ]),
          CommandGroup(heading: 'Settings', [
            CommandItem(
              'Profile',
              leading: const AshlarIcon(AshlarIconsRoundedStroke.user),
              shortcut: '⌘P',
              onSelected: () {},
            ),
            CommandItem(
              'Billing',
              keywords: const ['invoice', 'payment'],
              leading: const AshlarIcon(AshlarIconsRoundedStroke.creditCard),
              shortcut: '⌘B',
              onSelected: () {},
            ),
            CommandItem(
              'Settings',
              leading: const AshlarIcon(AshlarIconsRoundedStroke.settings),
              shortcut: '⌘S',
              onSelected: () {},
            ),
          ]),
        ],
      ),
    );
  }
}

Add it

In your Flutter project:

$ ashlar add command

It writes theme.dart, palette.dart, selection.dart, input.dart, group.dart, pressable.dart, button.dart, dialog.dart and command.dart into lib/ui, keeping any you already have, and adds ashlar_icons to pubspec.yaml. It needs input, dialog and ashlar_icons, which come with it.

Examples

Other ways to use it, each running live, with its code.

Groups and shortcuts

Type to narrow; arrows and Enter to choose.

Groups and shortcuts, as it looks in an app Groups and shortcuts, as it looks in an app, dark
// Groups and shortcuts: Type to narrow; arrows and Enter to choose.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';

import 'ui/command.dart';

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

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 420,
    child: Command(
      autofocus: false,
      groups: [
        CommandGroup(heading: 'Suggestions', [
          CommandItem('Calendar', leading: const AshlarIcon(AshlarIconsRoundedStroke.calendar), onSelected: () {}),
          CommandItem(
            'Search emoji',
            leading: const AshlarIcon(AshlarIconsRoundedStroke.faceSlightlySmiling),
            onSelected: () {},
          ),
        ]),
        CommandGroup(heading: 'Settings', [
          CommandItem(
            'Profile',
            shortcut: '⌘P',
            leading: const AshlarIcon(AshlarIconsRoundedStroke.user),
            onSelected: () {},
          ),
          CommandItem(
            'Billing',
            shortcut: '⌘B',
            leading: const AshlarIcon(AshlarIconsRoundedStroke.creditCard),
            onSelected: () {},
          ),
        ]),
      ],
    ),
  );
}

In a dialog

Opened from a button, as with ⌘K.

In a dialog, as it looks in an app In a dialog, as it looks in an app, dark
// In a dialog: Opened from a button, as with ⌘K.
import 'package:flutter/widgets.dart';

import 'ui/button.dart';
import 'ui/command.dart';

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

  @override
  Widget build(BuildContext context) => Builder(
    builder: (context) => Button(
      variant: ButtonVariant.outline,
      onPressed: () => showCommandDialog(
        context: context,
        groups: [
          CommandGroup([
            CommandItem('New file', onSelected: () {}),
            CommandItem('Open recent', onSelected: () {}),
          ]),
        ],
      ),
      child: const Text('Open the command menu'),
    ),
  );
}

API

Read from the source above: each parameter with its type, its default, and what it is for.

CommandItemclass

Something a Command can do or pick.

Parameter Type Default What it is for
labelpositional String What the item says.
onSelectedrequired VoidCallback Called when it is chosen.
keywords List<String> const [] Other words it is found by: "invoice" for "Billing".
leading Widget? Before the label: usually an icon.
shortcut String? A key combination, shown faint at the end: '⌘K'.
enabled bool true False fades it and stops it being chosen.

CommandGroupclass

A titled run of items in a Command.

Parameter Type Default What it is for
itemspositional List<CommandItem> The group's items.
headingString?The group's heading.

Commandclass

A search box over a list of things to do: typing narrows the list, the arrow keys move the highlight while the typing stays in the box, and Enter picks. For a menu of many commands, or a picker over a long list; showCommandDialog opens one over the page, the way ⌘K does.

Parameter Type Default What it is for
groupsrequired List<CommandGroup> The items, in titled groups.
placeholder String 'Type a command or search…' Faint text shown while it is empty.
emptyText String 'No results found.' Shown when nothing matches what was typed.
autofocus bool true Takes the keyboard's focus when it first appears.
maxHeight double 300 The tallest the list grows before it scrolls.

showCommandDialogfunction

Opens a Command over the page, the way ⌘K does. Picking an item closes it first.

Parameter Type Default What it is for
contextrequired BuildContext Where it is opened from: its theme and navigator come from here.
groupsrequired List<CommandGroup> The commands, in titled groups.
placeholder String 'Type a command or search…' Faint text in the search field.

Source

What lands in your app. It is yours from then on: change it as you would your own code, and ashlar add will not write over it.

lib/ui/command.dart 481 lines
// Ashlar UI: Command. Copied into your app by `ashlar add command`; it is yours to change.

import 'dart:math' show max;

import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';

import 'dialog.dart';
import 'input.dart';
import 'theme.dart';

/// Something a [Command] can do or pick.
@immutable
class CommandItem {
  const CommandItem(
    this.label, {
    required this.onSelected,
    this.keywords = const [],
    this.leading,
    this.shortcut,
    this.enabled = true,
  });

  /// What the item says.
  final String label;

  /// Called when it is chosen.
  final VoidCallback onSelected;

  /// Other words it is found by: "invoice" for "Billing".
  final List<String> keywords;

  /// Before the label: usually an icon.
  final Widget? leading;

  /// A key combination, shown faint at the end: '⌘K'.
  final String? shortcut;

  /// False fades it and stops it being chosen.
  final bool enabled;
}

/// A titled run of items in a [Command].
@immutable
class CommandGroup {
  const CommandGroup(this.items, {this.heading});

  /// The group's heading.
  final String? heading;

  /// The group's items.
  final List<CommandItem> items;
}

/// A search box over a list of things to do: typing narrows the list, the arrow keys
/// move the highlight while the typing stays in the box, and Enter picks. For a menu of
/// many commands, or a picker over a long list; [showCommandDialog] opens one over the
/// page, the way ⌘K does.
///
/// ```dart
/// Command(groups: [
///   CommandGroup(heading: 'Suggestions', [
///     CommandItem('Calendar', leading: AshlarIcon(AshlarIconsRoundedStroke.calendar), onSelected: openCalendar),
///     CommandItem('Search emoji', onSelected: openEmoji),
///   ]),
///   CommandGroup(heading: 'Settings', [CommandItem('Profile', shortcut: '⌘P', onSelected: openProfile)]),
/// ])
/// ```
class Command extends StatefulWidget {
  const Command({
    super.key,
    required this.groups,
    this.placeholder = 'Type a command or search…',
    this.emptyText = 'No results found.',
    this.autofocus = true,
    this.maxHeight = 300,
  });

  /// The items, in titled groups.
  final List<CommandGroup> groups;

  /// Faint text shown while it is empty.
  final String placeholder;

  /// Shown when nothing matches what was typed.
  final String emptyText;

  /// Takes the keyboard's focus when it first appears.
  final bool autofocus;

  /// The tallest the list grows before it scrolls.
  final double maxHeight;

  @override
  State<Command> createState() => _CommandState();
}

class _CommandState extends State<Command> {
  final _query = TextEditingController();
  final _scroll = ScrollController();
  int _at = 0;

  // The list as rows, worked out once per search rather than on every frame: headings,
  // lines between groups, and the items, with where each item sits.
  String? _for;
  List<_Line> _lines = const [];
  List<int> _items = const [];

  static const _itemHeight = 32.0,
      _headingHeight = 28.0,
      _gapHeight = 9.0,
      _pad = 4.0;

  @override
  void initState() {
    super.initState();
    _query.addListener(() => setState(() => _at = 0));
  }

  @override
  void dispose() {
    _query.dispose();
    _scroll.dispose();
    super.dispose();
  }

  void _layout() {
    if (_for == _query.text && _lines.isNotEmpty) return;
    _for = _query.text;
    final words = _query.text
        .toLowerCase()
        .split(RegExp(r'\s+'))
        .where((w) => w.isNotEmpty)
        .toList();
    // Every word typed is somewhere in the item's label or keywords.
    bool matches(CommandItem item) {
      final hay = [item.label, ...item.keywords].join(' ').toLowerCase();
      return words.every(hay.contains);
    }

    final lines = <_Line>[], items = <int>[];
    for (final g in widget.groups) {
      final hits = g.items.where(matches).toList();
      if (hits.isEmpty) continue;
      if (lines.isNotEmpty) lines.add(const _Line.gap());
      if (g.heading != null) lines.add(_Line.heading(g.heading!));
      for (final item in hits) {
        if (item.enabled) items.add(lines.length);
        lines.add(_Line.item(item));
      }
    }
    _lines = lines;
    _items = items;
  }

  /// Rows grow with large text: the font's scale, never below one.
  double _grow = 1;

  double _extent(_Line l) =>
      (l.item != null
          ? _itemHeight
          : (l.heading != null ? _headingHeight : _gapHeight)) *
      _grow;

  /// Where a row starts in the list, from the top of the list's padding.
  double _top(int line) {
    var y = _pad;
    for (var i = 0; i < line; i++) {
      y += _extent(_lines[i]);
    }
    return y;
  }

  void _move(int by) {
    final n = _items.length;
    if (n == 0) return;
    setState(() => _at = (_at + by).clamp(0, n - 1));
    if (!_scroll.hasClients) return;
    // Scroll just enough to bring the highlighted row into view.
    final top = _top(_items[_at]), bottom = top + _itemHeight;
    final view = _scroll.position;
    if (top - _pad < view.pixels) {
      _scroll.jumpTo((top - _pad).clamp(0, view.maxScrollExtent));
    } else if (bottom + _pad > view.pixels + view.viewportDimension) {
      _scroll.jumpTo(
        (bottom + _pad - view.viewportDimension).clamp(0, view.maxScrollExtent),
      );
    }
  }

  void _pick() {
    if (_at < _items.length) _lines[_items[_at]].item!.onSelected();
  }

  @override
  void didUpdateWidget(Command old) {
    super.didUpdateWidget(old);
    if (old.groups != widget.groups) _for = null;
  }

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    _layout();
    final lit = _items.isEmpty
        ? null
        : _lines[_items[_at.clamp(0, _items.length - 1)]].item;
    _grow = max(1, MediaQuery.textScalerOf(context).scale(14) / 14);
    final height = _lines.fold<double>(_pad * 2, (h, l) => h + _extent(l));
    return LayoutBuilder(
      builder: (context, box) => DefaultTextStyle.merge(
        style: t.text.sm.copyWith(color: c.popoverForeground),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            CallbackShortcuts(
              bindings: {
                const SingleActivator(LogicalKeyboardKey.arrowDown): () =>
                    _move(1),
                const SingleActivator(LogicalKeyboardKey.arrowUp): () =>
                    _move(-1),
                const SingleActivator(
                  LogicalKeyboardKey.home,
                  control: true,
                ): () =>
                    _move(-_items.length),
                const SingleActivator(
                  LogicalKeyboardKey.end,
                  control: true,
                ): () =>
                    _move(_items.length),
                const SingleActivator(LogicalKeyboardKey.enter): _pick,
              },
              child: Container(
                height: 44,
                padding: const EdgeInsets.symmetric(horizontal: 12),
                decoration: BoxDecoration(
                  border: Border(bottom: BorderSide(color: c.border)),
                ),
                child: Row(
                  children: [
                    AshlarIcon(
                      AshlarIconsRoundedStroke.search,
                      size: 16,
                      color: c.mutedForeground.withValues(alpha: .5),
                    ),
                    const SizedBox(width: 8),
                    Expanded(
                      child: Input(
                        decorated: false,
                        controller: _query,
                        autofocus: widget.autofocus,
                        placeholder: widget.placeholder,
                        textInputAction: TextInputAction.go,
                        onSubmitted: (_) => _pick(),
                      ),
                    ),
                  ],
                ),
              ),
            ),
            // The highlighted item, read out as it changes: the typing stays in the box.
            Semantics(
              liveRegion: true,
              label: lit?.label ?? (_lines.isEmpty ? widget.emptyText : ''),
              child: const SizedBox(),
            ),
            // In a bounded height (a popover near the screen's edge) the list gives way to
            // the search box; in a scrolling page it keeps its own height.
            _GiveWay(
              bounded: box.maxHeight.isFinite,
              child: _lines.isEmpty
                  ? Padding(
                      padding: const EdgeInsets.symmetric(vertical: 24),
                      child: Text(
                        widget.emptyText,
                        textAlign: TextAlign.center,
                        style: TextStyle(color: c.mutedForeground),
                      ),
                    )
                  // Only the rows on screen are built, however long the list.
                  : SizedBox(
                      height: height < widget.maxHeight
                          ? height
                          : widget.maxHeight,
                      child: ListView.builder(
                        controller: _scroll,
                        padding: const EdgeInsets.all(_pad),
                        itemCount: _lines.length,
                        itemExtentBuilder: (i, _) =>
                            i < _lines.length ? _extent(_lines[i]) : null,
                        itemBuilder: (context, i) {
                          final line = _lines[i];
                          if (line.item case final item?) {
                            return _Row(
                              item: item,
                              lit: identical(item, lit),
                              onHover: () {
                                final at = _items.indexOf(i);
                                if (at >= 0 && at != _at) {
                                  setState(() => _at = at);
                                }
                              },
                            );
                          }
                          if (line.heading case final heading?) {
                            return Padding(
                              padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
                              child: Semantics(
                                header: true,
                                child: Text(
                                  heading,
                                  style: t.text.xs.copyWith(
                                    color: c.mutedForeground,
                                    fontWeight: FontWeight.w500,
                                  ),
                                ),
                              ),
                            );
                          }
                          return Container(
                            height: 1,
                            margin: const EdgeInsets.symmetric(vertical: 4),
                            color: c.border,
                          );
                        },
                      ),
                    ),
            ),
          ],
        ),
      ),
    );
  }
}

/// A row of the list: a heading, a line between groups, or an item.
class _Line {
  const _Line.gap() : heading = null, item = null;
  const _Line.heading(String this.heading) : item = null;
  const _Line.item(CommandItem this.item) : heading = null;

  final String? heading;
  final CommandItem? item;
}

/// Flexible in a bounded column, as it is otherwise.
class _GiveWay extends StatelessWidget {
  const _GiveWay({required this.bounded, required this.child});

  final bool bounded;
  final Widget child;

  @override
  Widget build(BuildContext context) =>
      bounded ? Flexible(child: child) : child;
}

class _Row extends StatelessWidget {
  const _Row({required this.item, required this.lit, required this.onHover});

  final CommandItem item;
  final bool lit;
  final VoidCallback onHover;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    final fg = lit ? c.accentForeground : c.popoverForeground;
    return MouseRegion(
      cursor: item.enabled
          ? SystemMouseCursors.click
          : SystemMouseCursors.basic,
      onEnter: item.enabled ? (_) => onHover() : null,
      child: Semantics(
        button: true,
        selected: lit,
        enabled: item.enabled,
        onTap: item.enabled ? item.onSelected : null,
        child: GestureDetector(
          behavior: HitTestBehavior.opaque,
          onTap: item.enabled ? item.onSelected : null,
          child: Opacity(
            opacity: item.enabled ? 1 : .5,
            child: Container(
              padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
              decoration: BoxDecoration(
                color: lit ? c.accent : c.accent.withValues(alpha: 0),
                borderRadius: BorderRadius.circular(t.radiusSm),
              ),
              child: IconTheme.merge(
                data: IconThemeData(color: c.mutedForeground, size: 16),
                child: Row(
                  children: [
                    if (item.leading != null) ...[
                      item.leading!,
                      const SizedBox(width: 8),
                    ],
                    Expanded(
                      child: Text(
                        item.label,
                        style: TextStyle(color: fg),
                        maxLines: 1,
                        overflow: TextOverflow.ellipsis,
                      ),
                    ),
                    if (item.shortcut != null)
                      Text(
                        item.shortcut!,
                        style: t.text.xs.copyWith(
                          color: c.mutedForeground,
                          letterSpacing: 1.2,
                        ),
                      ),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

/// Opens a [Command] over the page, the way ⌘K does. Picking an item closes it first.
Future<void> showCommandDialog({
  required BuildContext context,
  required List<CommandGroup> groups,
  String placeholder = 'Type a command or search…',
}) => showAshlarDialog<void>(
  context: context,
  builder: (context) {
    final t = AshlarTheme.of(context);
    void close() => Navigator.pop(context);
    return SafeArea(
      child: Align(
        alignment: const Alignment(0, -.4),
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: ConstrainedBox(
            constraints: const BoxConstraints(maxWidth: 512),
            child: Container(
              clipBehavior: Clip.antiAlias,
              decoration: BoxDecoration(
                color: t.colors.popover,
                borderRadius: BorderRadius.circular(t.radiusLg),
                border: Border.all(color: t.colors.border),
                boxShadow: t.shadowLg,
              ),
              child: Command(
                placeholder: placeholder,
                groups: [
                  for (final g in groups)
                    CommandGroup(heading: g.heading, [
                      for (final i in g.items)
                        CommandItem(
                          i.label,
                          keywords: i.keywords,
                          leading: i.leading,
                          shortcut: i.shortcut,
                          enabled: i.enabled,
                          onSelected: () {
                            close();
                            i.onSelected();
                          },
                        ),
                    ]),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  },
);