Vyuh CDX

Command Palette

Powerful command palette with instant access and keyboard shortcuts

Command Palette and Shortcuts

The Vyuh Entity System includes a powerful command palette that provides instant access to any entity, action, or navigation item in your application. This guide covers the command palette system, keyboard shortcuts, and how to extend it with custom commands.

Command Palette Overview

The command palette is a global search interface that allows users to:

  • Search and navigate to any entity
  • Execute actions without navigation
  • Access recently used items
  • Use keyboard shortcuts for common operations

Core Components

CommandPaletteService

The central service that manages all searchable items:

class CommandPaletteService extends ChangeNotifier {
  final List<NavigationItem> _items = [];
  final List<String> _recentItemIds = [];
  final int maxRecentItems;
  
  CommandPaletteService({this.maxRecentItems = 10});
  
  void registerItem(NavigationItem item) {
    _items.add(item);
    notifyListeners();
  }
  
  void registerItems(List<NavigationItem> items) {
    _items.addAll(items);
    notifyListeners();
  }
  
  void unregisterItem(String id) {
    _items.removeWhere((item) => item.id == id);
    notifyListeners();
  }
  
  List<NavigationItem> search(String query) {
    if (query.isEmpty) {
      return getRecentItems();
    }
    
    final lowerQuery = query.toLowerCase();
    final results = <NavigationItem>[];
    
    // Exact matches first
    results.addAll(_items.where((item) => 
      item.title.toLowerCase() == lowerQuery ||
      item.subtitle?.toLowerCase() == lowerQuery
    ));
    
    // Prefix matches
    results.addAll(_items.where((item) =>
      item.title.toLowerCase().startsWith(lowerQuery) &&
      !results.contains(item)
    ));
    
    // Contains matches
    results.addAll(_items.where((item) =>
      (item.title.toLowerCase().contains(lowerQuery) ||
       item.subtitle?.toLowerCase().contains(lowerQuery) == true ||
       item.keywords.any((k) => k.toLowerCase().contains(lowerQuery))) &&
      !results.contains(item)
    ));
    
    // Fuzzy matching
    results.addAll(_fuzzySearch(query, _items)
      .where((item) => !results.contains(item)));
    
    return results.take(20).toList();
  }
  
  List<NavigationItem> getRecentItems() {
    return _recentItemIds
      .map((id) => _items.firstWhereOrNull((item) => item.id == id))
      .whereType<NavigationItem>()
      .toList();
  }
  
  void markAsUsed(String itemId) {
    _recentItemIds.remove(itemId);
    _recentItemIds.insert(0, itemId);
    
    if (_recentItemIds.length > maxRecentItems) {
      _recentItemIds.removeLast();
    }
    
    notifyListeners();
  }
  
  List<NavigationItem> _fuzzySearch(String query, List<NavigationItem> items) {
    // Implement fuzzy search algorithm
    final scored = <(NavigationItem, double)>[];
    
    for (final item in items) {
      final score = _calculateFuzzyScore(query, item.title);
      if (score > 0.5) {
        scored.add((item, score));
      }
    }
    
    scored.sort((a, b) => b.$2.compareTo(a.$2));
    return scored.map((e) => e.$1).toList();
  }
  
  double _calculateFuzzyScore(String query, String target) {
    // Simple fuzzy scoring algorithm
    query = query.toLowerCase();
    target = target.toLowerCase();
    
    if (target.contains(query)) return 1.0;
    
    var score = 0.0;
    var targetIndex = 0;
    
    for (var i = 0; i < query.length; i++) {
      final char = query[i];
      final index = target.indexOf(char, targetIndex);
      
      if (index == -1) {
        score -= 0.1;
      } else {
        score += 1.0 / (index - targetIndex + 1);
        targetIndex = index + 1;
      }
    }
    
    return score / query.length;
  }
}
class NavigationItem {
  final String id;
  final String title;
  final String? subtitle;
  final IconData? icon;
  final String route;
  final NavigationItemType type;
  final List<String> keywords;
  final Map<String, dynamic>? metadata;
  final Future<void> Function(BuildContext)? action;
  final bool Function(BuildContext)? isEnabled;
  
  const NavigationItem({
    required this.id,
    required this.title,
    required this.route,
    this.subtitle,
    this.icon,
    this.type = NavigationItemType.navigation,
    this.keywords = const [],
    this.metadata,
    this.action,
    this.isEnabled,
  });
  
  // Factory constructors for common types
  factory NavigationItem.entity({
    required EntityMetadata metadata,
    required String route,
  }) {
    return NavigationItem(
      id: 'entity_${metadata.identifier}',
      title: metadata.pluralName,
      subtitle: metadata.description,
      icon: metadata.icon,
      route: route,
      type: NavigationItemType.entity,
      keywords: [
        metadata.name,
        metadata.pluralName,
        metadata.identifier,
      ],
    );
  }
  
  factory NavigationItem.action({
    required String id,
    required String title,
    required Future<void> Function(BuildContext) action,
    String? subtitle,
    IconData? icon,
    List<String> keywords = const [],
  }) {
    return NavigationItem(
      id: id,
      title: title,
      subtitle: subtitle,
      icon: icon,
      route: '', // Actions don't navigate
      type: NavigationItemType.action,
      keywords: keywords,
      action: action,
    );
  }
}

enum NavigationItemType {
  navigation,
  entity,
  action,
  setting,
  help,
}

Command Palette UI

CommandPaletteDialog

The main command palette interface:

class CommandPaletteDialog extends StatefulWidget {
  @override
  State<CommandPaletteDialog> createState() => _CommandPaletteDialogState();
}

class _CommandPaletteDialogState extends State<CommandPaletteDialog> {
  final _controller = TextEditingController();
  final _focusNode = FocusNode();
  List<NavigationItem> _results = [];
  int _selectedIndex = 0;
  
  @override
  void initState() {
    super.initState();
    _focusNode.requestFocus();
    _updateResults('');
  }
  
  void _updateResults(String query) {
    final service = context.read<CommandPaletteService>();
    setState(() {
      _results = service.search(query);
      _selectedIndex = 0;
    });
  }
  
  void _executeItem(NavigationItem item) {
    final service = context.read<CommandPaletteService>();
    service.markAsUsed(item.id);
    
    Navigator.of(context).pop();
    
    if (item.action != null) {
      item.action!(context);
    } else if (item.route.isNotEmpty) {
      context.go(item.route);
    }
  }
  
  @override
  Widget build(BuildContext context) {
    return Dialog(
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(12),
      ),
      child: Container(
        width: 600,
        height: 400,
        child: Column(
          children: [
            // Search input
            Container(
              padding: const EdgeInsets.all(16),
              decoration: BoxDecoration(
                border: Border(
                  bottom: BorderSide(
                    color: Theme.of(context).dividerColor,
                  ),
                ),
              ),
              child: TextField(
                controller: _controller,
                focusNode: _focusNode,
                decoration: InputDecoration(
                  hintText: 'Search for entities, actions, or commands...',
                  prefixIcon: const Icon(Icons.search),
                  suffixIcon: IconButton(
                    icon: const Icon(Icons.clear),
                    onPressed: () {
                      _controller.clear();
                      _updateResults('');
                    },
                  ),
                  border: OutlineInputBorder(
                    borderRadius: BorderRadius.circular(8),
                  ),
                ),
                onChanged: _updateResults,
                onSubmitted: (_) {
                  if (_results.isNotEmpty) {
                    _executeItem(_results[_selectedIndex]);
                  }
                },
              ),
            ),
            
            // Results list
            Expanded(
              child: _results.isEmpty
                ? Center(
                    child: Text(
                      _controller.text.isEmpty
                        ? 'Start typing to search'
                        : 'No results found',
                      style: Theme.of(context).textTheme.bodyLarge?.copyWith(
                        color: Theme.of(context).textTheme.bodySmall?.color,
                      ),
                    ),
                  )
                : ListView.builder(
                    itemCount: _results.length,
                    itemBuilder: (context, index) {
                      final item = _results[index];
                      final isSelected = index == _selectedIndex;
                      
                      return ListTile(
                        leading: Icon(
                          item.icon ?? _getIconForType(item.type),
                          color: isSelected
                            ? Theme.of(context).primaryColor
                            : null,
                        ),
                        title: Text(item.title),
                        subtitle: item.subtitle != null
                          ? Text(item.subtitle!)
                          : null,
                        trailing: _buildTrailing(item),
                        selected: isSelected,
                        onTap: () => _executeItem(item),
                        onHover: (hovering) {
                          if (hovering) {
                            setState(() => _selectedIndex = index);
                          }
                        },
                      );
                    },
                  ),
            ),
            
            // Footer
            Container(
              padding: const EdgeInsets.all(8),
              decoration: BoxDecoration(
                color: Theme.of(context).colorScheme.surface,
                border: Border(
                  top: BorderSide(
                    color: Theme.of(context).dividerColor,
                  ),
                ),
              ),
              child: Row(
                children: [
                  Text(
                    '↑↓ Navigate',
                    style: Theme.of(context).textTheme.bodySmall,
                  ),
                  const SizedBox(width: 16),
                  Text(
                    '↵ Select',
                    style: Theme.of(context).textTheme.bodySmall,
                  ),
                  const SizedBox(width: 16),
                  Text(
                    'ESC Close',
                    style: Theme.of(context).textTheme.bodySmall,
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
  
  IconData _getIconForType(NavigationItemType type) {
    switch (type) {
      case NavigationItemType.entity:
        return Icons.folder;
      case NavigationItemType.action:
        return Icons.play_arrow;
      case NavigationItemType.setting:
        return Icons.settings;
      case NavigationItemType.help:
        return Icons.help;
      default:
        return Icons.arrow_forward;
    }
  }
  
  Widget? _buildTrailing(NavigationItem item) {
    if (item.type == NavigationItemType.action) {
      return const Chip(
        label: Text('Action'),
        labelStyle: TextStyle(fontSize: 12),
      );
    }
    
    // Show keyboard shortcut if available
    final shortcut = item.metadata?['shortcut'] as String?;
    if (shortcut != null) {
      return Text(
        shortcut,
        style: Theme.of(context).textTheme.bodySmall,
      );
    }
    
    return null;
  }
}

Keyboard Handling

class CommandPaletteKeyboardHandler extends StatefulWidget {
  final Widget child;
  
  const CommandPaletteKeyboardHandler({required this.child});
  
  @override
  State<CommandPaletteKeyboardHandler> createState() => 
    _CommandPaletteKeyboardHandlerState();
}

class _CommandPaletteKeyboardHandlerState 
    extends State<CommandPaletteKeyboardHandler> {
  
  @override
  Widget build(BuildContext context) {
    return CallbackShortcuts(
      bindings: {
        // Cmd/Ctrl+K to open command palette
        LogicalKeySet(
          Platform.isMacOS 
            ? LogicalKeyboardKey.meta 
            : LogicalKeyboardKey.control,
          LogicalKeyboardKey.keyK,
        ): () => _showCommandPalette(context),
        
        // Cmd/Ctrl+Shift+P for actions only
        LogicalKeySet(
          Platform.isMacOS 
            ? LogicalKeyboardKey.meta 
            : LogicalKeyboardKey.control,
          LogicalKeyboardKey.shift,
          LogicalKeyboardKey.keyP,
        ): () => _showActionPalette(context),
        
        // Custom shortcuts
        ...getCustomShortcuts(context),
      },
      child: Focus(
        autofocus: true,
        child: widget.child,
      ),
    );
  }
  
  void _showCommandPalette(BuildContext context) {
    showDialog(
      context: context,
      builder: (_) => CommandPaletteDialog(),
    );
  }
  
  void _showActionPalette(BuildContext context) {
    showDialog(
      context: context,
      builder: (_) => CommandPaletteDialog(
        filter: NavigationItemType.action,
      ),
    );
  }
  
  Map<LogicalKeySet, VoidCallback> getCustomShortcuts(BuildContext context) {
    final shortcuts = <LogicalKeySet, VoidCallback>{};
    final service = context.read<CommandPaletteService>();
    
    // Register shortcuts from navigation items
    for (final item in service.getAllItems()) {
      final shortcut = item.metadata?['keyboardShortcut'] as LogicalKeySet?;
      if (shortcut != null) {
        shortcuts[shortcut] = () {
          if (item.action != null) {
            item.action!(context);
          } else if (item.route.isNotEmpty) {
            context.go(item.route);
          }
        };
      }
    }
    
    return shortcuts;
  }
}

Entity Integration

Automatic Entity Registration

class EntityCommandPaletteIntegration {
  static void registerEntity<T extends EntityBase>(
    EntityConfiguration<T> config,
    CommandPaletteService service,
  ) {
    final metadata = config.metadata;
    
    // Register list view
    service.registerItem(NavigationItem(
      id: 'entity_list_${metadata.identifier}',
      title: 'View ${metadata.pluralName}',
      subtitle: 'Browse all ${metadata.pluralName.toLowerCase()}',
      icon: metadata.icon,
      route: metadata.route.list(null),
      type: NavigationItemType.entity,
      keywords: [
        metadata.name,
        metadata.pluralName,
        'list',
        'browse',
        'all',
      ],
    ));
    
    // Register create action
    service.registerItem(NavigationItem(
      id: 'entity_create_${metadata.identifier}',
      title: 'Create ${metadata.name}',
      subtitle: 'Add a new ${metadata.name.toLowerCase()}',
      icon: Icons.add,
      route: metadata.route.create(null),
      type: NavigationItemType.entity,
      keywords: [
        metadata.name,
        'create',
        'add',
        'new',
      ],
      metadata: {
        'shortcut': 'Cmd+N',
        'keyboardShortcut': LogicalKeySet(
          LogicalKeyboardKey.meta,
          LogicalKeyboardKey.keyN,
        ),
      },
    ));
    
    // Register entity-specific actions
    if (config.actions != null) {
      for (final action in config.actions!.list ?? []) {
        service.registerItem(NavigationItem.action(
          id: 'action_${metadata.identifier}_${action.label}',
          title: '${action.label} ${metadata.pluralName}',
          icon: action.icon,
          action: (context) async {
            // Get current entities and execute action
            final provider = context.read<EntityProvider<T>>();
            final entities = await provider.list();
            await action.onTap(context, entities);
          },
          keywords: [
            metadata.name,
            metadata.pluralName,
            action.label.toLowerCase(),
          ],
        ));
      }
    }
  }
}

Entity Search Provider

class EntitySearchProvider<T extends EntityBase> {
  final EntityApi<T> api;
  final EntityMetadata metadata;
  
  EntitySearchProvider({
    required this.api,
    required this.metadata,
  });
  
  Future<List<NavigationItem>> searchEntities(String query) async {
    if (query.length < 2) return [];
    
    try {
      final entities = await api.list(
        search: query,
        limit: 10,
      );
      
      return entities.map((entity) => NavigationItem(
        id: 'entity_${metadata.identifier}_${entity.id}',
        title: _getEntityTitle(entity),
        subtitle: _getEntitySubtitle(entity),
        icon: metadata.icon,
        route: metadata.route.view(null, entity.id),
        type: NavigationItemType.entity,
        metadata: {
          'entity': entity,
          'entityType': T.toString(),
        },
      )).toList();
    } catch (e) {
      // Log error but don't break search
      debugPrint('Error searching ${metadata.identifier}: $e');
      return [];
    }
  }
  
  String _getEntityTitle(T entity) {
    // Try common field names
    final json = entity.toJson();
    return json['name'] ?? 
           json['title'] ?? 
           json['label'] ?? 
           '${metadata.name} ${entity.id}';
  }
  
  String _getEntitySubtitle(T entity) {
    final json = entity.toJson();
    final parts = <String>[];
    
    // Add common descriptive fields
    if (json['description'] != null) {
      parts.add(json['description']);
    }
    if (json['status'] != null) {
      parts.add('Status: ${json['status']}');
    }
    if (entity.createdAt != null) {
      parts.add('Created: ${_formatDate(entity.createdAt!)}');
    }
    
    return parts.join(' • ');
  }
  
  String _formatDate(DateTime date) {
    final now = DateTime.now();
    final difference = now.difference(date);
    
    if (difference.inDays == 0) {
      return 'Today';
    } else if (difference.inDays == 1) {
      return 'Yesterday';
    } else if (difference.inDays < 7) {
      return '${difference.inDays} days ago';
    } else {
      return DateFormat('MMM d, y').format(date);
    }
  }
}

Custom Commands

Creating Custom Commands

abstract class CommandHandler {
  String get id;
  String get title;
  String? get description;
  IconData? get icon;
  List<String> get keywords;
  
  bool canHandle(String input);
  Future<void> execute(BuildContext context, String input);
}

class CalculatorCommand extends CommandHandler {
  @override
  String get id => 'calculator';
  
  @override
  String get title => 'Calculator';
  
  @override
  String get description => 'Perform quick calculations';
  
  @override
  IconData get icon => Icons.calculate;
  
  @override
  List<String> get keywords => ['calc', 'math', 'calculate'];
  
  @override
  bool canHandle(String input) {
    // Check if input is a mathematical expression
    return RegExp(r'^[\d\s\+\-\*\/\(\)\.]+$').hasMatch(input);
  }
  
  @override
  Future<void> execute(BuildContext context, String input) async {
    try {
      // Parse and evaluate expression
      final result = _evaluateExpression(input);
      
      // Show result
      showDialog(
        context: context,
        builder: (_) => AlertDialog(
          title: const Text('Calculation Result'),
          content: Text('$input = $result'),
          actions: [
            TextButton(
              onPressed: () {
                // Copy to clipboard
                Clipboard.setData(ClipboardData(text: result.toString()));
                Navigator.of(context).pop();
              },
              child: const Text('Copy'),
            ),
            TextButton(
              onPressed: () => Navigator.of(context).pop(),
              child: const Text('Close'),
            ),
          ],
        ),
      );
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Invalid expression: $e')),
      );
    }
  }
  
  double _evaluateExpression(String expression) {
    // Simple expression evaluator
    // In production, use a proper math expression parser
    return 42.0; // Placeholder
  }
}

Command Registry

class CommandRegistry {
  final List<CommandHandler> _handlers = [];
  
  void register(CommandHandler handler) {
    _handlers.add(handler);
  }
  
  void registerAll(List<CommandHandler> handlers) {
    _handlers.addAll(handlers);
  }
  
  CommandHandler? findHandler(String input) {
    for (final handler in _handlers) {
      if (handler.canHandle(input)) {
        return handler;
      }
    }
    return null;
  }
  
  List<NavigationItem> getCommandItems() {
    return _handlers.map((handler) => NavigationItem(
      id: 'command_${handler.id}',
      title: handler.title,
      subtitle: handler.description,
      icon: handler.icon,
      route: '',
      type: NavigationItemType.action,
      keywords: handler.keywords,
      action: (context) => _showCommandInput(context, handler),
    )).toList();
  }
  
  void _showCommandInput(BuildContext context, CommandHandler handler) {
    showDialog(
      context: context,
      builder: (_) => CommandInputDialog(handler: handler),
    );
  }
}

Advanced Features

Command History

class CommandHistory {
  final List<CommandHistoryEntry> _entries = [];
  final int maxEntries;
  
  CommandHistory({this.maxEntries = 100});
  
  void add(CommandHistoryEntry entry) {
    _entries.insert(0, entry);
    
    if (_entries.length > maxEntries) {
      _entries.removeLast();
    }
  }
  
  List<CommandHistoryEntry> search(String query) {
    if (query.isEmpty) {
      return _entries.take(10).toList();
    }
    
    return _entries
      .where((entry) => 
        entry.command.toLowerCase().contains(query.toLowerCase()) ||
        entry.title.toLowerCase().contains(query.toLowerCase())
      )
      .take(10)
      .toList();
  }
  
  void clear() {
    _entries.clear();
  }
}

class CommandHistoryEntry {
  final String command;
  final String title;
  final NavigationItemType type;
  final DateTime timestamp;
  final Map<String, dynamic>? metadata;
  
  const CommandHistoryEntry({
    required this.command,
    required this.title,
    required this.type,
    required this.timestamp,
    this.metadata,
  });
}

Command Aliases

class CommandAliasService {
  final Map<String, String> _aliases = {
    // Built-in aliases
    'u': 'users',
    'p': 'products',
    'new': 'create',
    'add': 'create',
    'del': 'delete',
    'rm': 'delete',
    'ls': 'list',
    'show': 'view',
  };
  
  void registerAlias(String alias, String target) {
    _aliases[alias.toLowerCase()] = target.toLowerCase();
  }
  
  String expandAliases(String input) {
    final parts = input.split(' ');
    final expanded = parts.map((part) => 
      _aliases[part.toLowerCase()] ?? part
    );
    
    return expanded.join(' ');
  }
  
  Map<String, String> getAllAliases() => Map.unmodifiable(_aliases);
}

Smart Suggestions

class SmartSuggestionService {
  final CommandPaletteService paletteService;
  final CommandHistory history;
  
  SmartSuggestionService({
    required this.paletteService,
    required this.history,
  });
  
  List<NavigationItem> getSuggestions(BuildContext context) {
    final suggestions = <NavigationItem>[];
    
    // Time-based suggestions
    final hour = DateTime.now().hour;
    if (hour < 12) {
      // Morning suggestions
      suggestions.add(NavigationItem(
        id: 'suggestion_dashboard',
        title: 'View Dashboard',
        subtitle: 'Start your day with an overview',
        icon: Icons.dashboard,
        route: '/dashboard',
      ));
    }
    
    // Context-based suggestions
    final currentRoute = GoRouter.of(context).location;
    if (currentRoute.startsWith('/users')) {
      suggestions.add(NavigationItem(
        id: 'suggestion_create_user',
        title: 'Create New User',
        icon: Icons.person_add,
        route: '/users/new',
      ));
    }
    
    // Frequency-based suggestions
    final frequentCommands = _getFrequentCommands();
    suggestions.addAll(frequentCommands);
    
    return suggestions.take(5).toList();
  }
  
  List<NavigationItem> _getFrequentCommands() {
    // Analyze history for frequent commands
    final frequency = <String, int>{};
    
    for (final entry in history._entries) {
      frequency[entry.command] = (frequency[entry.command] ?? 0) + 1;
    }
    
    final sorted = frequency.entries.toList()
      ..sort((a, b) => b.value.compareTo(a.value));
    
    return sorted.take(3).map((entry) {
      final item = paletteService.getAllItems()
        .firstWhereOrNull((item) => item.title == entry.key);
      
      return item ?? NavigationItem(
        id: 'freq_${entry.key}',
        title: entry.key,
        route: '',
      );
    }).toList();
  }
}

Configuration

Command Palette Settings

class CommandPaletteSettings {
  final bool enableFuzzySearch;
  final bool showRecentItems;
  final int maxRecentItems;
  final bool enableKeyboardShortcuts;
  final bool showCommandHistory;
  final Duration searchDebounce;
  final bool enableSmartSuggestions;
  
  const CommandPaletteSettings({
    this.enableFuzzySearch = true,
    this.showRecentItems = true,
    this.maxRecentItems = 10,
    this.enableKeyboardShortcuts = true,
    this.showCommandHistory = true,
    this.searchDebounce = const Duration(milliseconds: 300),
    this.enableSmartSuggestions = true,
  });
  
  CommandPaletteSettings copyWith({
    bool? enableFuzzySearch,
    bool? showRecentItems,
    int? maxRecentItems,
    bool? enableKeyboardShortcuts,
    bool? showCommandHistory,
    Duration? searchDebounce,
    bool? enableSmartSuggestions,
  }) {
    return CommandPaletteSettings(
      enableFuzzySearch: enableFuzzySearch ?? this.enableFuzzySearch,
      showRecentItems: showRecentItems ?? this.showRecentItems,
      maxRecentItems: maxRecentItems ?? this.maxRecentItems,
      enableKeyboardShortcuts: enableKeyboardShortcuts ?? this.enableKeyboardShortcuts,
      showCommandHistory: showCommandHistory ?? this.showCommandHistory,
      searchDebounce: searchDebounce ?? this.searchDebounce,
      enableSmartSuggestions: enableSmartSuggestions ?? this.enableSmartSuggestions,
    );
  }
}

Best Practices

  1. Descriptive Titles - Use clear, action-oriented titles
  2. Keywords - Include relevant keywords for better search
  3. Icons - Use consistent icons for similar actions
  4. Performance - Debounce search queries
  5. Accessibility - Ensure keyboard navigation works properly
  6. Shortcuts - Document keyboard shortcuts clearly
  7. Recent Items - Track and display recently used items

Next: Activity Tracking - Audit trails and activity logging