add Zed-style application menu bar: File / View / Help (T-48)
A custom in-window menu bar in the hat (no native menu, D-7), built from the command registry so it stays in sync and satisfies D-6 parity. - Menu model + hybrid resolver (menu_model.dart): a curated File/View/Help tree where a MenuAutoFill node sweeps in unplaced view.* commands; titles + keybindings come from the registry/keymap; unregistered or enabledWhen-false items render disabled (greyed), never hidden. - Widgets: MenuBar row in the hat (chrome tokens), anchored MenuDropdown overlay (dropdown tokens), two-column MenuItemRow with inline keybinding. - Full keyboard: Alt+mnemonic opens (hook in _RootShell._onKey), arrows navigate, Enter activates, Esc closes, Left/Right switch menus. - Commands: file.openFolder / file.newWindow / file.closeWorkspace / help.about, registered by MenuBarExtension(services:). File logic lifted out of the project switcher into FileActions (one source of truth; the switcher now dispatches the commands). Ctrl+O / Ctrl+Shift+N are now real keybindings in default.yaml. - Help → About: version/commit/date/repo from build-info + the bundled dependency licenses parsed from assets/licenses.yaml. Edit/Selection menus are deferred to T-271/T-272 (need focused-surface command routing). Tests: resolver + controller + licenses parse (pure); menu-bar widget (open/close/execute/disabled/Esc/arrow/Enter/Left-Right); FileActions + Open dialog; app-level Alt+F, non-repo dialog, and closeWorkspace. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
/// Menu model + resolver for the application menu bar (T-48).
|
||||
///
|
||||
/// The menu is **hybrid**: a hand-authored [TopMenu] tree fixes the curated
|
||||
/// placement (ordering, grouping, separators), and a [MenuAutoFill] node sweeps
|
||||
/// in any registered command sharing a prefix that wasn't explicitly placed.
|
||||
/// Titles and keybindings are pulled from the [CommandRegistry] / keymap at
|
||||
/// resolve time, so the menu always reflects the live command set. Items whose
|
||||
/// command isn't registered, or whose [MenuCommandItem.enabledWhen] predicate
|
||||
/// fails, resolve to **disabled** (greyed) — visible, never hidden.
|
||||
library;
|
||||
|
||||
import 'package:clide/extension/extension.dart' show CommandContribution;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authoring model (the curated tree)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A node in a curated menu.
|
||||
sealed class MenuNode {
|
||||
const MenuNode();
|
||||
}
|
||||
|
||||
/// An explicit placement of a registry command. Title + keybinding come from
|
||||
/// the registry at resolve time; [fallbackTitle] is used only when the command
|
||||
/// isn't registered (in which case the item is disabled).
|
||||
class MenuCommandItem extends MenuNode {
|
||||
const MenuCommandItem(this.commandId, {this.fallbackTitle, this.enabledWhen});
|
||||
final String commandId;
|
||||
final String? fallbackTitle;
|
||||
|
||||
/// Extra enablement predicate (beyond "is registered"). When it returns
|
||||
/// false the item shows greyed. Used e.g. to grey "Close Project" when no
|
||||
/// project is open.
|
||||
final bool Function(KernelServices)? enabledWhen;
|
||||
}
|
||||
|
||||
/// A horizontal rule between groups.
|
||||
class MenuSeparator extends MenuNode {
|
||||
const MenuSeparator();
|
||||
}
|
||||
|
||||
/// Sweep in every registered command whose id starts with [prefix] and isn't
|
||||
/// explicitly placed elsewhere in the tree, sorted by title.
|
||||
class MenuAutoFill extends MenuNode {
|
||||
const MenuAutoFill(this.prefix);
|
||||
final String prefix;
|
||||
}
|
||||
|
||||
/// A top-level menu (File / View / Help).
|
||||
class TopMenu {
|
||||
const TopMenu({required this.title, required this.mnemonic, required this.nodes});
|
||||
final String title;
|
||||
|
||||
/// Index into [title] of the Alt-mnemonic letter (e.g. 0 → 'F' in "File").
|
||||
final int mnemonic;
|
||||
final List<MenuNode> nodes;
|
||||
|
||||
/// The lower-case mnemonic character, for matching `Alt+<letter>`.
|
||||
String get mnemonicChar => title[mnemonic].toLowerCase();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolved model (render-ready)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
sealed class ResolvedNode {
|
||||
const ResolvedNode();
|
||||
}
|
||||
|
||||
class ResolvedItem extends ResolvedNode {
|
||||
const ResolvedItem({required this.commandId, required this.title, required this.enabled, this.keybinding});
|
||||
final String commandId;
|
||||
final String title;
|
||||
final bool enabled;
|
||||
final String? keybinding;
|
||||
}
|
||||
|
||||
class ResolvedSeparator extends ResolvedNode {
|
||||
const ResolvedSeparator();
|
||||
}
|
||||
|
||||
class ResolvedMenu {
|
||||
const ResolvedMenu({required this.title, required this.mnemonic, required this.items});
|
||||
final String title;
|
||||
final int mnemonic;
|
||||
final List<ResolvedNode> items;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolver
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The human-readable keybinding label for [commandId] from the live keymap
|
||||
/// (respecting user overrides), or null if unbound. Joins multi-chord
|
||||
/// sequences with spaces.
|
||||
String? keymapBindingLabel(KeymapService keymap, String commandId) {
|
||||
for (final b in keymap.effectiveBindings) {
|
||||
final i = b.intent;
|
||||
if (i is InvokeCommandIntent && i.commandId == commandId && b.sequence.isNotEmpty) {
|
||||
return b.sequence.map((c) => c.display).join(' ');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Resolve the curated [tree] against the live command set into render-ready
|
||||
/// menus. [bindingLabel] supplies the keybinding string for a command id
|
||||
/// (typically [keymapBindingLabel] bound to the keymap); when it returns null
|
||||
/// the command's own `defaultBinding` is used as a fallback.
|
||||
List<ResolvedMenu> resolveMenus(
|
||||
List<TopMenu> tree,
|
||||
CommandRegistry registry,
|
||||
KernelServices services, {
|
||||
String? Function(String commandId)? bindingLabel,
|
||||
}) {
|
||||
final placed = <String>{
|
||||
for (final m in tree)
|
||||
for (final n in m.nodes)
|
||||
if (n is MenuCommandItem) n.commandId,
|
||||
};
|
||||
|
||||
String? label(String id) {
|
||||
final fromKeymap = bindingLabel?.call(id);
|
||||
if (fromKeymap != null) return fromKeymap;
|
||||
final db = registry.get(id)?.defaultBinding;
|
||||
if (db == null || db.isEmpty) return null;
|
||||
try {
|
||||
return KeyChord.parse(db).display;
|
||||
} catch (_) {
|
||||
return db;
|
||||
}
|
||||
}
|
||||
|
||||
ResolvedItem resolveItem(MenuCommandItem item) {
|
||||
final cmd = registry.get(item.commandId);
|
||||
final enabled = cmd != null && (item.enabledWhen?.call(services) ?? true);
|
||||
final raw = cmd?.title ?? item.fallbackTitle ?? item.commandId;
|
||||
return ResolvedItem(commandId: item.commandId, title: _stripCategory(raw), enabled: enabled, keybinding: label(item.commandId));
|
||||
}
|
||||
|
||||
List<ResolvedNode> expand(MenuNode n) => switch (n) {
|
||||
MenuCommandItem() => [resolveItem(n)],
|
||||
MenuSeparator() => const [ResolvedSeparator()],
|
||||
MenuAutoFill(:final prefix) => [
|
||||
for (final c in _autoFill(registry, prefix, placed)) resolveItem(MenuCommandItem(c.command)),
|
||||
],
|
||||
};
|
||||
|
||||
return [
|
||||
for (final m in tree) ResolvedMenu(title: m.title, mnemonic: m.mnemonic, items: [for (final n in m.nodes) ...expand(n)]),
|
||||
];
|
||||
}
|
||||
|
||||
List<CommandContribution> _autoFill(CommandRegistry registry, String prefix, Set<String> placed) {
|
||||
final hits = registry.all.where((c) => c.command.startsWith(prefix) && !placed.contains(c.command)).toList();
|
||||
hits.sort((a, b) => (a.title ?? a.command).toLowerCase().compareTo((b.title ?? b.command).toLowerCase()));
|
||||
return hits;
|
||||
}
|
||||
|
||||
/// Strip a leading "Category: " prefix so "View: Zoom In" reads "Zoom In"
|
||||
/// under the View menu. Only strips a single capitalised word + colon + space.
|
||||
String _stripCategory(String title) {
|
||||
final m = RegExp(r'^[A-Z][A-Za-z]*:\s').firstMatch(title);
|
||||
return m == null ? title : title.substring(m.end);
|
||||
}
|
||||
Reference in New Issue
Block a user