shortcut_atlas 0.1.0 copy "shortcut_atlas: ^0.1.0" to clipboard
shortcut_atlas: ^0.1.0 copied to clipboard

Enterprise keyboard shortcuts for Flutter desktop: bind keys to actions and reveal a contextual cheat-sheet HUD by holding Alt.

example/lib/main.dart

import 'dart:convert';
import 'dart:ui' show ImageFilter;

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shortcut_atlas/shortcut_atlas.dart';

void main() => runApp(const DemoApp());

/// Which built-in HUD preset the demo is showing off.
enum HudStyle { glass, minimal, accent }

/// Persists user re-bindings to `shared_preferences` as a single JSON blob.
/// This is all it takes to make [Keymap] durable — encode/decode is handled by
/// the package's [ActivatorCodec] under the hood.
class SharedPrefsKeymapStore implements KeymapStore {
  static const _key = 'shortcut_atlas.keymap';

  @override
  Future<Map<String, dynamic>> load() async {
    final prefs = await SharedPreferences.getInstance();
    final raw = prefs.getString(_key);
    if (raw == null || raw.isEmpty) return const {};
    return (jsonDecode(raw) as Map).cast<String, dynamic>();
  }

  @override
  Future<void> save(Map<String, dynamic> data) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_key, jsonEncode(data));
  }
}

class DemoApp extends StatefulWidget {
  const DemoApp({super.key});

  @override
  State<DemoApp> createState() => _DemoAppState();
}

class _DemoAppState extends State<DemoApp> {
  static const List<(String, Color)> swatches = [
    ('Indigo', Color(0xFF5C6BC0)),
    ('Violet', Color(0xFF7C4DFF)),
    ('Blue', Color(0xFF2196F3)),
    ('Teal', Color(0xFF009688)),
    ('Green', Color(0xFF43A047)),
    ('Amber', Color(0xFFFFB300)),
    ('Orange', Color(0xFFFB8C00)),
    ('Rose', Color(0xFFEC407A)),
  ];

  Color _seed = swatches.first.$2;
  ThemeMode _mode = ThemeMode.dark;
  HudStyle _hudStyle = HudStyle.glass;

  // One store for the whole app; re-bindings survive restarts.
  final SharedPrefsKeymapStore _keymapStore = SharedPrefsKeymapStore();

  /// Maps the selected preset to a HUD theme. `glass` returns null so the
  /// package's theme-derived default is used.
  AtlasThemeData? _hudTheme(BuildContext context) => switch (_hudStyle) {
        HudStyle.glass => null,
        HudStyle.minimal => AtlasThemeData.minimal(context),
        HudStyle.accent => AtlasThemeData.accent(context),
      };

  ThemeData _theme(Brightness brightness) => ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: _seed, brightness: brightness),
        useMaterial3: true,
      );

  void _toggleDark() => setState(
      () => _mode = _mode == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'shortcut_atlas demo',
      theme: _theme(Brightness.light),
      darkTheme: _theme(Brightness.dark),
      themeMode: _mode,
      // Install the shortcut system once, above the app's Navigator. The HUD
      // inherits the accent automatically from the ambient theme. Passing a
      // keymapStore makes user re-bindings persistent.
      builder: (context, child) => ShortcutAtlas(
        theme: _hudTheme(context),
        config: AtlasConfig(keymapStore: _keymapStore),
        child: child!,
      ),
      home: HomeScreen(
        swatches: swatches,
        seed: _seed,
        isDark: _mode == ThemeMode.dark,
        hudStyle: _hudStyle,
        onSeedChanged: (c) => setState(() => _seed = c),
        onToggleDark: _toggleDark,
        onHudStyleChanged: (s) => setState(() => _hudStyle = s),
      ),
    );
  }
}

/// One demo command: a key chord wired to a behavior, surfaced as a card, a HUD
/// row, and a palette entry from a single declaration.
class _Demo {
  const _Demo(this.id, this.label, this.description, this.icon, this.activator,
      this.group, this.onInvoke);
  final String id;
  final String label;
  final String description;
  final IconData icon;
  final ShortcutActivator activator;
  final ShortcutGroup group;
  final VoidCallback onInvoke;
}

class HomeScreen extends StatefulWidget {
  const HomeScreen({
    super.key,
    required this.swatches,
    required this.seed,
    required this.isDark,
    required this.hudStyle,
    required this.onSeedChanged,
    required this.onToggleDark,
    required this.onHudStyleChanged,
  });

  final List<(String, Color)> swatches;
  final Color seed;
  final bool isDark;
  final HudStyle hudStyle;
  final ValueChanged<Color> onSeedChanged;
  final VoidCallback onToggleDark;
  final ValueChanged<HudStyle> onHudStyleChanged;

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  final List<String> _docs = ['Document 1', 'Document 2', 'Document 3'];
  final FocusNode _searchFocus = FocusNode();
  int _active = 0;
  double _zoom = 1.0;
  String _status = 'Hold Alt to reveal every shortcut. Press Ctrl/Cmd+K for the palette.';

  void _say(String s) => setState(() => _status = s);

  void _openTab(int i) {
    if (i < _docs.length) {
      setState(() => _active = i);
      _say('Switched to ${_docs[i]}');
    }
  }

  void _newTab() {
    setState(() {
      _docs.add('Document ${_docs.length + 1}');
      _active = _docs.length - 1;
    });
    _say('Opened ${_docs[_active]}');
  }

  void _closeTab() {
    if (_docs.length <= 1) {
      _say('Cannot close the last document');
      return;
    }
    setState(() {
      final removed = _docs.removeAt(_active);
      _active = _active.clamp(0, _docs.length - 1);
      _say('Closed $removed');
    });
  }

  Future<void> _newWindow() async {
    _say('Opened a new window');
    await Navigator.of(context).push(
      MaterialPageRoute<void>(builder: (_) => const DocumentWindow()),
    );
  }

  void _save() => _say('Saved ${_docs[_active]}');

  void _find() {
    _searchFocus.requestFocus();
    _say('Find — focus moved to the search box');
  }

  void _zoomBy(double delta) {
    setState(() => _zoom = (_zoom + delta).clamp(0.5, 2.0));
    _say('Zoom ${(_zoom * 100).round()}%');
  }

  void _resetZoom() {
    setState(() => _zoom = 1.0);
    _say('Zoom reset to 100%');
  }

  void _openPalette() => ShortcutAtlas.of(context).openPalette();

  @override
  void dispose() {
    _searchFocus.dispose();
    super.dispose();
  }

  List<_Demo> get _demos => [
        _Demo('file.newWindow', 'New Window', 'Open another editor window',
            Icons.open_in_new_rounded, primary(LogicalKeyboardKey.keyN),
            ShortcutGroup.file, _newWindow),
        _Demo('file.newTab', 'New Tab', 'Add a document tab',
            Icons.add_box_outlined, primary(LogicalKeyboardKey.keyT),
            ShortcutGroup.tabs, _newTab),
        _Demo('file.close', 'Close Tab', 'Close the active document',
            Icons.close_rounded, primary(LogicalKeyboardKey.keyW),
            ShortcutGroup.tabs, _closeTab),
        _Demo('file.save', 'Save', 'Save the active document',
            Icons.save_outlined, primary(LogicalKeyboardKey.keyS),
            ShortcutGroup.file, _save),
        _Demo('edit.find', 'Find', 'Focus the search box',
            Icons.search_rounded, primary(LogicalKeyboardKey.keyF),
            ShortcutGroup.edit, _find),
        _Demo('view.zoomIn', 'Zoom In', 'Increase the zoom level',
            Icons.zoom_in_rounded, primary(LogicalKeyboardKey.equal),
            ShortcutGroup.view, () => _zoomBy(0.1)),
        _Demo('view.zoomOut', 'Zoom Out', 'Decrease the zoom level',
            Icons.zoom_out_rounded, primary(LogicalKeyboardKey.minus),
            ShortcutGroup.view, () => _zoomBy(-0.1)),
        _Demo('view.zoomReset', 'Reset Zoom', 'Back to 100%',
            Icons.restart_alt_rounded, primary(LogicalKeyboardKey.digit0),
            ShortcutGroup.view, _resetZoom),
        _Demo('view.toggleTheme', 'Toggle Theme', 'Switch light / dark',
            Icons.brightness_6_rounded, primary(LogicalKeyboardKey.keyD),
            ShortcutGroup.view, widget.onToggleDark),
        _Demo('view.palette', 'Command Palette', 'Search every command',
            Icons.bolt_rounded, primary(LogicalKeyboardKey.keyK),
            ShortcutGroup.general, _openPalette),
      ];

  /// The single source of truth: every shortcut is declared once here and then
  /// drives dispatch, the HUD, the palette, the menu bar, and the re-bind panel.
  List<ShortcutCommand> _buildCommands(List<_Demo> demos) => [
        for (final d in demos)
          if (d.id != 'view.palette') // the palette opener is built in
            command(d.id,
                activator: d.activator,
                label: d.label,
                description: d.description,
                group: d.group,
                icon: d.icon,
                onInvoke: d.onInvoke),
        for (var i = 0; i < _docs.length && i < 9; i++)
          command('tabs.open.$i',
              activator: CharacterActivator('${i + 1}'),
              label: 'Open ${_docs[i]}',
              group: ShortcutGroup.tabs,
              icon: Icons.tab_rounded,
              onInvoke: () => _openTab(i)),
      ];

  @override
  Widget build(BuildContext context) {
    final demos = _demos;
    final commands = _buildCommands(demos);
    return ShortcutScope(
      kind: ScopeKind.screen,
      autofocus: true,
      commands: commands,
      child: _Shell(demos: demos, commands: commands, state: this),
    );
  }
}

class _Shell extends StatelessWidget {
  const _Shell({required this.demos, required this.commands, required this.state});

  final List<_Demo> demos;
  final List<ShortcutCommand> commands;
  final _HomeScreenState state;

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    final dark = Theme.of(context).brightness == Brightness.dark;
    final gradient = dark
        ? [
            Color.alphaBlend(cs.primary.withValues(alpha: 0.30), cs.surface),
            Color.alphaBlend(cs.tertiary.withValues(alpha: 0.26), cs.surface),
          ]
        : [cs.primary, cs.tertiary];

    return Scaffold(
      body: Container(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topLeft,
            end: Alignment.bottomRight,
            colors: gradient,
          ),
        ),
        child: SafeArea(
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: ClipRRect(
              borderRadius: BorderRadius.circular(28),
              child: BackdropFilter(
                filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
                child: Container(
                  decoration: BoxDecoration(
                    color: cs.surface.withValues(alpha: 0.82),
                    borderRadius: BorderRadius.circular(28),
                    border:
                        Border.all(color: cs.outlineVariant.withValues(alpha: 0.6)),
                  ),
                  padding: const EdgeInsets.all(28),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    children: [
                      _AppMenuBar(commands: commands),
                      const SizedBox(height: 18),
                      Expanded(
                        child: SingleChildScrollView(
                          child: Column(
                            crossAxisAlignment: CrossAxisAlignment.stretch,
                            children: [
                              _Header(),
                              const SizedBox(height: 24),
                              _ThemeControls(state: state),
                              const SizedBox(height: 24),
                              _Documents(state: state),
                              const SizedBox(height: 24),
                              _SearchBox(state: state),
                              const SizedBox(height: 24),
                              _CardGrid(demos: demos),
                              const SizedBox(height: 22),
                              _HelpCard(),
                            ],
                          ),
                        ),
                      ),
                      const SizedBox(height: 16),
                      _StatusCard(status: state._status),
                    ],
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _Header extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return Row(
      children: [
        Container(
          padding: const EdgeInsets.all(14),
          decoration: BoxDecoration(
            color: cs.primaryContainer,
            borderRadius: BorderRadius.circular(18),
          ),
          child: Icon(Icons.keyboard_command_key_rounded,
              color: cs.onPrimaryContainer, size: 30),
        ),
        const SizedBox(width: 18),
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text('shortcut_atlas',
                  style: Theme.of(context)
                      .textTheme
                      .headlineSmall
                      ?.copyWith(fontWeight: FontWeight.w700)),
              const SizedBox(height: 2),
              Text(
                'Keyboard shortcuts + a hold-Alt HUD for desktop. '
                'Detected: ${AtlasPlatform.name} · primary modifier '
                '${AtlasPlatform.primaryModifierLabel}.',
                style: Theme.of(context)
                    .textTheme
                    .bodyMedium
                    ?.copyWith(color: cs.onSurfaceVariant),
              ),
            ],
          ),
        ),
      ],
    );
  }
}

class _ThemeControls extends StatelessWidget {
  const _ThemeControls({required this.state});
  final _HomeScreenState state;

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    final w = state.widget;
    return Row(
      crossAxisAlignment: CrossAxisAlignment.end,
      children: [
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text('Accent color',
                  style: Theme.of(context)
                      .textTheme
                      .labelMedium
                      ?.copyWith(color: cs.onSurfaceVariant)),
              const SizedBox(height: 10),
              Wrap(
                spacing: 10,
                runSpacing: 10,
                children: [
                  for (final (name, color) in w.swatches)
                    _SwatchDot(
                      name: name,
                      color: color,
                      selected: w.seed.toARGB32() == color.toARGB32(),
                      onTap: () => w.onSeedChanged(color),
                    ),
                ],
              ),
            ],
          ),
        ),
        const SizedBox(width: 20),
        Column(
          crossAxisAlignment: CrossAxisAlignment.end,
          children: [
            SegmentedButton<bool>(
              showSelectedIcon: false,
              segments: const [
                ButtonSegment(
                    value: false,
                    icon: Icon(Icons.light_mode_outlined, size: 18),
                    label: Text('Light')),
                ButtonSegment(
                    value: true,
                    icon: Icon(Icons.dark_mode_outlined, size: 18),
                    label: Text('Dark')),
              ],
              selected: {w.isDark},
              onSelectionChanged: (_) => w.onToggleDark(),
            ),
            const SizedBox(height: 10),
            SegmentedButton<HudStyle>(
              showSelectedIcon: false,
              segments: const [
                ButtonSegment(value: HudStyle.glass, label: Text('Glass')),
                ButtonSegment(value: HudStyle.minimal, label: Text('Minimal')),
                ButtonSegment(value: HudStyle.accent, label: Text('Accent')),
              ],
              selected: {w.hudStyle},
              onSelectionChanged: (s) => w.onHudStyleChanged(s.first),
            ),
          ],
        ),
      ],
    );
  }
}

class _Documents extends StatelessWidget {
  const _Documents({required this.state});
  final _HomeScreenState state;

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          children: [
            Text('Documents',
                style: Theme.of(context)
                    .textTheme
                    .titleMedium
                    ?.copyWith(fontWeight: FontWeight.w600)),
            const SizedBox(width: 10),
            Text('press 1 – ${state._docs.length} to switch',
                style: Theme.of(context)
                    .textTheme
                    .bodySmall
                    ?.copyWith(color: cs.onSurfaceVariant)),
          ],
        ),
        const SizedBox(height: 12),
        Wrap(
          spacing: 10,
          runSpacing: 10,
          children: [
            for (var i = 0; i < state._docs.length; i++)
              ChoiceChip(
                label: Text('${i + 1}  ·  ${state._docs[i]}'),
                selected: state._active == i,
                onSelected: (_) => state._openTab(i),
              ),
          ],
        ),
        const SizedBox(height: 8),
        Text('Zoom ${(state._zoom * 100).round()}%',
            style: Theme.of(context)
                .textTheme
                .bodySmall
                ?.copyWith(color: cs.onSurfaceVariant)),
      ],
    );
  }
}

class _SearchBox extends StatelessWidget {
  const _SearchBox({required this.state});
  final _HomeScreenState state;

  @override
  Widget build(BuildContext context) {
    return TextField(
      focusNode: state._searchFocus,
      decoration: const InputDecoration(
        prefixIcon: Icon(Icons.search),
        border: OutlineInputBorder(),
        labelText: 'Search (try typing 1, 2, 3 — bare shortcuts are guarded here)',
      ),
    );
  }
}

class _CardGrid extends StatelessWidget {
  const _CardGrid({required this.demos});
  final List<_Demo> demos;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Text('Shortcuts',
            style: Theme.of(context)
                .textTheme
                .titleMedium
                ?.copyWith(fontWeight: FontWeight.w600)),
        const SizedBox(height: 4),
        Text('Click a card, press its keys, or hold Alt to see them all.',
            style: Theme.of(context).textTheme.bodySmall),
        const SizedBox(height: 14),
        LayoutBuilder(
          builder: (context, constraints) {
            final cols = (constraints.maxWidth / 300).floor().clamp(1, 3);
            final cellWidth = (constraints.maxWidth - (cols - 1) * 14) / cols;
            return Wrap(
              spacing: 14,
              runSpacing: 14,
              children: [
                for (final d in demos)
                  _ShortcutCard(demo: d, width: cellWidth),
              ],
            );
          },
        ),
      ],
    );
  }
}

class _ShortcutCard extends StatelessWidget {
  const _ShortcutCard({required this.demo, required this.width});
  final _Demo demo;
  final double width;

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    final glyphs = GlyphSet.of(context).glyphsFor(demo.activator);
    return SizedBox(
      width: width,
      child: Material(
        color: cs.surfaceContainerHighest,
        borderRadius: BorderRadius.circular(16),
        clipBehavior: Clip.antiAlias,
        child: InkWell(
          onTap: demo.onInvoke,
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
            child: Row(
              children: [
                Container(
                  width: 44,
                  height: 44,
                  decoration: BoxDecoration(
                    color: cs.primaryContainer,
                    borderRadius: BorderRadius.circular(12),
                  ),
                  child: Icon(demo.icon, color: cs.onPrimaryContainer, size: 22),
                ),
                const SizedBox(width: 14),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      Text(demo.label,
                          style: const TextStyle(
                              fontSize: 15.5, fontWeight: FontWeight.w600)),
                      const SizedBox(height: 2),
                      Text(demo.description,
                          maxLines: 1,
                          overflow: TextOverflow.ellipsis,
                          style: TextStyle(
                              fontSize: 12, color: cs.onSurfaceVariant)),
                      const SizedBox(height: 8),
                      Row(
                        children: [
                          for (final g in glyphs) ...[
                            _KbdChip(g),
                            const SizedBox(width: 4),
                          ],
                        ],
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class _KbdChip extends StatelessWidget {
  const _KbdChip(this.glyph);
  final String glyph;

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return Container(
      constraints: const BoxConstraints(minWidth: 22, minHeight: 22),
      padding: const EdgeInsets.symmetric(horizontal: 6),
      alignment: Alignment.center,
      decoration: BoxDecoration(
        color: cs.surface,
        borderRadius: BorderRadius.circular(6),
        border: Border.all(color: cs.outlineVariant),
      ),
      child: Text(glyph,
          style: TextStyle(
              fontSize: 12, fontWeight: FontWeight.w600, color: cs.onSurface)),
    );
  }
}

class _SwatchDot extends StatelessWidget {
  const _SwatchDot({
    required this.name,
    required this.color,
    required this.selected,
    required this.onTap,
  });
  final String name;
  final Color color;
  final bool selected;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return Tooltip(
      message: name,
      child: InkWell(
        onTap: onTap,
        borderRadius: BorderRadius.circular(20),
        child: AnimatedContainer(
          duration: const Duration(milliseconds: 150),
          width: 32,
          height: 32,
          decoration: BoxDecoration(
            color: color,
            shape: BoxShape.circle,
            border: Border.all(
              color: selected ? cs.onSurface : Colors.transparent,
              width: 2.5,
            ),
          ),
          child: selected
              ? const Icon(Icons.check, size: 16, color: Colors.white)
              : null,
        ),
      ),
    );
  }
}

class _HelpCard extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    const tips = [
      'Hold Alt to reveal a contextual cheat-sheet of every active shortcut',
      'Press Ctrl/Cmd+K to open the searchable command palette',
      'Use the File / Edit / View menu bar — same commands, same chords',
      'Click the tune icon (top-right) to remap any shortcut; it persists',
      'Switch the HUD between Glass / Minimal / Accent presets above',
      'Type 1/2/3 in the search box: bare shortcuts are auto-disabled while editing',
      'Ctrl/Cmd+N opens a new window with its own scoped shortcuts',
    ];
    return Container(
      padding: const EdgeInsets.all(20),
      decoration: BoxDecoration(
        color: cs.surfaceContainerLowest.withValues(alpha: 0.5),
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: cs.outlineVariant),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              Icon(Icons.lightbulb_outline, color: cs.primary, size: 20),
              const SizedBox(width: 8),
              Text('Things to try',
                  style: Theme.of(context)
                      .textTheme
                      .titleSmall
                      ?.copyWith(fontWeight: FontWeight.w700)),
            ],
          ),
          const SizedBox(height: 14),
          for (final tip in tips)
            Padding(
              padding: const EdgeInsets.only(bottom: 10),
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Padding(
                    padding: const EdgeInsets.only(top: 1),
                    child: Icon(Icons.check_circle_outline,
                        size: 17, color: cs.primary),
                  ),
                  const SizedBox(width: 10),
                  Expanded(
                      child: Text(tip,
                          style: Theme.of(context).textTheme.bodyMedium)),
                ],
              ),
            ),
        ],
      ),
    );
  }
}

class _StatusCard extends StatelessWidget {
  const _StatusCard({required this.status});
  final String status;

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: cs.surfaceContainerHigh,
        borderRadius: BorderRadius.circular(14),
      ),
      child: Row(
        children: [
          Icon(Icons.bolt_rounded, size: 18, color: cs.onSurfaceVariant),
          const SizedBox(width: 10),
          Expanded(
            child: Text(status,
                style: const TextStyle(fontFamily: 'monospace', fontSize: 13)),
          ),
        ],
      ),
    );
  }
}

/// Feature 2 — a native-style menu bar generated from the same command list.
/// Each item shows the command's *effective* chord and runs the same behavior
/// the key would; the hint is display-only, so it never double-fires.
class _AppMenuBar extends StatelessWidget {
  const _AppMenuBar({required this.commands});
  final List<ShortcutCommand> commands;

  ShortcutCommand? _byId(String id) {
    for (final c in commands) {
      if (c.id == id) return c;
    }
    return null;
  }

  ShortcutMenu _menu(String label, List<String> ids) {
    final cmds = <ShortcutCommand>[];
    for (final id in ids) {
      final c = _byId(id);
      if (c != null) cmds.add(c);
    }
    return ShortcutMenu(label, cmds);
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Expanded(
          child: ShortcutMenuBar(
            menus: [
              _menu('File', const [
                'file.newWindow',
                'file.newTab',
                'file.close',
                'file.save',
              ]),
              _menu('Edit', const ['edit.find']),
              _menu('View', const [
                'view.zoomIn',
                'view.zoomOut',
                'view.zoomReset',
                'view.toggleTheme',
              ]),
            ],
          ),
        ),
        IconButton(
          tooltip: 'Customize shortcuts',
          icon: const Icon(Icons.tune_rounded),
          onPressed: () => showDialog<void>(
            context: context,
            builder: (_) => _ShortcutSettingsDialog(commands: commands),
          ),
        ),
      ],
    );
  }
}

/// Feature 1 — a re-bind panel. Click a chip, press a new chord, and the change
/// flows everywhere (dispatch, HUD, palette, menu) and is persisted by the
/// app's [KeymapStore].
class _ShortcutSettingsDialog extends StatelessWidget {
  const _ShortcutSettingsDialog({required this.commands});
  final List<ShortcutCommand> commands;

  @override
  Widget build(BuildContext context) {
    final controller = ShortcutAtlas.of(context);
    final keymap = controller.keymap;
    final rebindable = [
      for (final c in commands)
        if (!c.id.startsWith('tabs.open')) c,
    ];
    return AlertDialog(
      title: Row(
        children: const [
          Icon(Icons.tune_rounded),
          SizedBox(width: 10),
          Text('Customize Shortcuts'),
        ],
      ),
      content: SizedBox(
        width: 480,
        child: ListenableBuilder(
          listenable: keymap.revision,
          builder: (context, _) => ConstrainedBox(
            constraints: const BoxConstraints(maxHeight: 440),
            child: SingleChildScrollView(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  for (final c in rebindable)
                    Padding(
                      padding: const EdgeInsets.symmetric(vertical: 6),
                      child: Row(
                        children: [
                          Icon(c.icon, size: 20),
                          const SizedBox(width: 12),
                          Expanded(child: Text(c.label)),
                          KeyRecorder(
                            key: ValueKey('${c.id}.${keymap.revision.value}'),
                            initial: controller.effectiveActivator(c),
                            onRecorded: (a) => keymap.rebind(c.id, a),
                          ),
                          IconButton(
                            tooltip: 'Reset to default',
                            icon: const Icon(Icons.undo_rounded, size: 18),
                            onPressed: keymap.hasUserOverride(c.id)
                                ? () => keymap.reset(c.id)
                                : null,
                          ),
                        ],
                      ),
                    ),
                ],
              ),
            ),
          ),
        ),
      ),
      actions: [
        TextButton(
          onPressed: keymap.resetAll,
          child: const Text('Reset all'),
        ),
        FilledButton(
          onPressed: () => Navigator.of(context).pop(),
          child: const Text('Done'),
        ),
      ],
    );
  }
}

/// A second "window" pushed by Ctrl/Cmd+N. It declares its own [ShortcutScope],
/// so while it is focused its shortcuts take precedence — a live demo of scope
/// nesting. (Real OS windows need a multi-window plugin; this is a route.)
class DocumentWindow extends StatelessWidget {
  const DocumentWindow({super.key});

  @override
  Widget build(BuildContext context) {
    return ShortcutScope(
      kind: ScopeKind.screen,
      autofocus: true,
      commands: [
        command('window.close', activator: primary(LogicalKeyboardKey.keyW),
            label: 'Close Window', group: ShortcutGroup.file,
            icon: Icons.close_rounded,
            onInvoke: () => Navigator.of(context).maybePop()),
      ],
      child: Scaffold(
        appBar: AppBar(title: const Text('New Window')),
        body: const Center(
          child: Padding(
            padding: EdgeInsets.all(32),
            child: Text(
              'This window has its own ShortcutScope.\n\n'
              'Hold Alt to see its shortcuts, or press Ctrl/Cmd+W to close it.',
              textAlign: TextAlign.center,
            ),
          ),
        ),
      ),
    );
  }
}
0
likes
160
points
5
downloads

Documentation

API reference

Publisher

verified publisherluminest.io

Weekly Downloads

Enterprise keyboard shortcuts for Flutter desktop: bind keys to actions and reveal a contextual cheat-sheet HUD by holding Alt.

Homepage
Repository (GitHub)
View/report issues

Topics

#keyboard #shortcuts #desktop #accessibility #productivity

License

MIT (license)

Dependencies

flutter

More

Packages that depend on shortcut_atlas