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;
}
}NavigationItem Model
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
- Descriptive Titles - Use clear, action-oriented titles
- Keywords - Include relevant keywords for better search
- Icons - Use consistent icons for similar actions
- Performance - Debounce search queries
- Accessibility - Ensure keyboard navigation works properly
- Shortcuts - Document keyboard shortcuts clearly
- Recent Items - Track and display recently used items
Next: Activity Tracking - Audit trails and activity logging