workbench_shell 0.2.1 copy "workbench_shell: ^0.2.1" to clipboard
workbench_shell: ^0.2.1 copied to clipboard

VS Code-style workbench layout shell for Flutter with activity bar, sidebar, editor area, tabbed bottom panel, status bar, menu bar, and structural primitives. Depends only on Flutter.

example/lib/main.dart

// workbench_shell example app.
//
// Renders a minimal workbench with five activity-bar items
// (Explorer, Search, Buttons, Notifications, Settings), VS Code's five
// canonical bottom panels (Problems, Output, Debug Console, Terminal,
// Ports) with their default keyboard bindings, a notification-center
// demo, and a status bar. Demonstrates the canonical integration
// pattern for pub.dev consumers: host owns its tab vocabulary and focus
// intent; the shell owns chrome and the panel-toggle default.
//
// The Output panel observes its `PanelLifecycle.isFocused` to drive
// a once-per-second counter that pauses while the tab is blurred or
// the bottom panel is hidden — the canonical focus-aware-content
// pattern.
//
// The Settings sidebar mirrors VS Code's color-theme settings layout:
// an "Auto detect color scheme" checkbox plus three dropdown fields
// — "Color theme" (active when auto-detect is off), "Preferred dark
// color theme", and "Preferred light color theme" — driven by
// `WorkbenchThemeController`. Switching themes rebuilds the
// `MaterialApp.theme` extension; every chrome surface (tab strip,
// status bar, sidebar headings) updates in the same frame, and the
// controller's brightness signal is forwarded across the canonical
// `workbench_shell/window_chrome` method channel so the macOS /
// Windows title bar tracks the workbench appearance (SPEC §spec:platform-brightness-sync).

import 'dart:async';

import 'package:flutter/foundation.dart' show defaultTargetPlatform;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:workbench_shell/workbench_shell.dart';

/// Canonical method channel for forwarding workbench brightness to the
/// host-window runner.
///
/// **Method:** `setBrightness`
/// **Argument:** `String` — `'light'` or `'dark'`
/// **Return:** none
///
/// The macOS and Windows runners in this example's source tree implement
/// the receiver (the title bar tracks the workbench appearance). The
/// published package archive omits the platform folders, so a consumer
/// who regenerates runners with `flutter create .` gets no receiver until
/// they add one. The channel is unidirectional Dart→host; Dart treats
/// `MissingPluginException` as a no-op, so the signal degrades cleanly
/// where no receiver exists (e.g. Linux, iOS, or a fresh runner).
const MethodChannel _windowChromeChannel = MethodChannel(
  'workbench_shell/window_chrome',
);

/// String payload for the canonical `setBrightness` method. Kept
/// stable so hosts can wire their runners against the same vocabulary
/// without depending on Flutter's `Brightness` enum encoding.
String _brightnessPayload(Brightness brightness) =>
    brightness == Brightness.dark ? 'dark' : 'light';

/// Forward [brightness] to the host-window runner. Best-effort —
/// platforms without a receiver report `MissingPluginException`,
/// which the example treats as a benign no-op.
Future<void> _publishBrightness(Brightness brightness) async {
  // The channel is only meaningful on the desktop targets that ship a
  // window runner. Skip the round-trip on platforms where no native
  // implementation exists; the platform's own appearance handling is
  // already correct on those targets.
  switch (defaultTargetPlatform) {
    case TargetPlatform.macOS:
    case TargetPlatform.windows:
      break;
    case TargetPlatform.linux:
    case TargetPlatform.android:
    case TargetPlatform.iOS:
    case TargetPlatform.fuchsia:
      return;
  }
  try {
    await _windowChromeChannel.invokeMethod<void>(
      'setBrightness',
      _brightnessPayload(brightness),
    );
  } on MissingPluginException {
    // Host has not registered the receiver yet (e.g. development
    // start-up race). Subsequent calls succeed once the runner wires
    // up.
  } on PlatformException {
    // Receiver rejected the payload. Swallow rather than crash the
    // example — the chrome still flips in-process.
  }
}

void main() {
  runApp(const WorkbenchExampleApp());
}

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

  @override
  State<WorkbenchExampleApp> createState() => _WorkbenchExampleAppState();
}

class _WorkbenchExampleAppState extends State<WorkbenchExampleApp> {
  late final WorkbenchThemeController _themeController;
  Brightness? _publishedBrightness;

  @override
  void initState() {
    super.initState();
    // Seed with a synchronous fallback theme so the first frame
    // paints with usable chrome. The controller resolves the real
    // bundled asset for the current brightness slot asynchronously
    // (default themeMode: system), notifyListeners fires when it
    // lands, and the AnimatedBuilder below rebuilds.
    _themeController = WorkbenchThemeController(
      initialTheme: WorkbenchTheme.fromVscodeColorMap(
        const VscodeColorMap(
          name: 'Example Dark',
          baseType: 'vs-dark',
          colors: {},
        ),
      ),
    );
    _themeController.addListener(_handleControllerChanged);
    // Push the initial brightness once after the first frame so the
    // host-window runner has its method channel registered before we
    // call.
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _handleControllerChanged();
    });
  }

  void _handleControllerChanged() {
    final next = _themeController.brightness;
    if (next == _publishedBrightness) return;
    _publishedBrightness = next;
    unawaited(_publishBrightness(next));
  }

  @override
  void dispose() {
    _themeController.removeListener(_handleControllerChanged);
    _themeController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _themeController,
      builder: (context, _) {
        final isDark = _themeController.brightness == Brightness.dark;
        return MaterialApp(
          title: 'workbench_shell example',
          debugShowCheckedModeBanner: false,
          // Build the example's ThemeData through the chrome helper so
          // the VS Code button tiers render with their resting fills and
          // 4px corners sourced from the chrome — no host wiring needed.
          // This makes the example a self-contained chrome review surface
          // (SPEC §spec:chrome-material-theming): chrome styling changes are reviewable by
          // running the example standalone.
          theme: applyWorkbenchChrome(
            isDark ? ThemeData.dark() : ThemeData.light(),
            _themeController.theme,
          ),
          home: WorkbenchHome(themeController: _themeController),
        );
      },
    );
  }
}

/// The five canonical bottom-panel tabs VS Code ships by default.
/// Lives in the host (this example), not in `workbench_shell` — the
/// shell does not opine about tab vocabulary.
enum ExamplePanel {
  problems('Problems'),
  output('Output'),
  debugConsole('Debug Console'),
  terminal('Terminal'),
  ports('Ports');

  const ExamplePanel(this.label);
  final String label;
}

/// Host-defined intent for focusing a specific bottom-panel tab.
/// `workbench_shell` ships only `ToggleBottomPanelIntent`; consumers
/// declare their own intents for host-specific commands.
class FocusExamplePanelIntent extends Intent {
  const FocusExamplePanelIntent(this.panel);
  final ExamplePanel panel;
}

class WorkbenchHome extends StatefulWidget {
  const WorkbenchHome({super.key, required this.themeController});

  final WorkbenchThemeController themeController;

  @override
  State<WorkbenchHome> createState() => _WorkbenchHomeState();
}

class _WorkbenchHomeState extends State<WorkbenchHome> {
  bool _panelVisible = true;
  void Function(Object id)? _focusPanelById;
  final NotificationService _notificationService = NotificationService();

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

  static const _activityBarItems = [
    ActivityBarItem(
      id: 'explorer',
      label: 'Explorer',
      icon: Symbols.folder_rounded,
    ),
    ActivityBarItem(
      id: 'search',
      label: 'Search',
      icon: Symbols.search_rounded,
    ),
    ActivityBarItem(
      id: 'buttons',
      label: 'Buttons',
      icon: Symbols.smart_button_rounded,
    ),
    ActivityBarItem(
      id: 'notifications',
      label: 'Notifications',
      icon: Symbols.notifications_rounded,
    ),
    ActivityBarItem(
      id: 'settings',
      label: 'Settings',
      icon: Symbols.settings_rounded,
      zone: ActivityBarZone.bottom,
    ),
  ];

  void _togglePanel() {
    setState(() => _panelVisible = !_panelVisible);
  }

  /// VS Code's defaults: Shift+Cmd+M Problems, Shift+Cmd+U Output,
  /// Shift+Cmd+Y Debug Console, Ctrl+` Terminal (Ctrl on every
  /// platform — matches VS Code itself). Ports has no default
  /// keyboard binding in VS Code.
  static MenuSerializableShortcut? _shortcutFor(ExamplePanel panel) {
    switch (panel) {
      case ExamplePanel.problems:
        return const SingleActivator(
          LogicalKeyboardKey.keyM,
          meta: true,
          shift: true,
        );
      case ExamplePanel.output:
        return const SingleActivator(
          LogicalKeyboardKey.keyU,
          meta: true,
          shift: true,
        );
      case ExamplePanel.debugConsole:
        return const SingleActivator(
          LogicalKeyboardKey.keyY,
          meta: true,
          shift: true,
        );
      case ExamplePanel.terminal:
        return const SingleActivator(
          LogicalKeyboardKey.backquote,
          control: true,
        );
      case ExamplePanel.ports:
        return null;
    }
  }

  /// `WorkbenchPanelHost.shortcuts` covers the meta-key variant of
  /// each tab's keybinding. To stay aligned with VS Code on Linux and
  /// Windows we install the parallel control-key bindings here.
  static const _ctrlVariantShortcuts = <ShortcutActivator, Intent>{
    SingleActivator(LogicalKeyboardKey.keyM, control: true, shift: true):
        FocusExamplePanelIntent(ExamplePanel.problems),
    SingleActivator(LogicalKeyboardKey.keyU, control: true, shift: true):
        FocusExamplePanelIntent(ExamplePanel.output),
    SingleActivator(LogicalKeyboardKey.keyY, control: true, shift: true):
        FocusExamplePanelIntent(ExamplePanel.debugConsole),
  };

  Widget _buildPanelContent(ExamplePanel panel, PanelLifecycle lifecycle) {
    if (panel == ExamplePanel.output) {
      return _OutputCounterBody(lifecycle: lifecycle);
    }
    return _PanelBodyPlaceholder(panel: panel);
  }

  List<WorkbenchPanel> _buildPanels() {
    return [
      for (final panel in ExamplePanel.values)
        WorkbenchPanel(
          id: panel,
          label: panel.label,
          shortcut: _shortcutFor(panel),
          focusIntent: FocusExamplePanelIntent(panel),
          contentBuilder: (ctx, lifecycle) =>
              _buildPanelContent(panel, lifecycle),
        ),
    ];
  }

  @override
  Widget build(BuildContext context) {
    return WorkbenchPanelHost(
      panels: _buildPanels(),
      panelVisible: _panelVisible,
      onTogglePanel: _togglePanel,
      initialActiveId: ExamplePanel.problems,
      onRegisterFocus: (focusById) => _focusPanelById = focusById,
      builder: (ctx, scope) {
        return Shortcuts(
          shortcuts: {...scope.shortcuts, ..._ctrlVariantShortcuts},
          child: WorkbenchShortcuts(
            child: Actions(
              actions: <Type, Action<Intent>>{
                ToggleBottomPanelIntent:
                    CallbackAction<ToggleBottomPanelIntent>(
                      onInvoke: (_) {
                        _togglePanel();
                        return null;
                      },
                    ),
                FocusExamplePanelIntent:
                    CallbackAction<FocusExamplePanelIntent>(
                      onInvoke: (intent) {
                        if (!_panelVisible) {
                          setState(() => _panelVisible = true);
                        }
                        _focusPanelById?.call(intent.panel);
                        return null;
                      },
                    ),
              },
              child: WorkbenchMenuBar(
                tabs: scope.viewMenuTabs,
                child: NotificationHost(
                  service: _notificationService,
                  bottomInset: WorkbenchLayoutConstants.statusBarHeight,
                  child: WorkbenchLayout(
                    activityBarItems: _activityBarItems,
                    sidebarBuilder: _buildSidebar,
                    editor: const _EditorPlaceholder(),
                    bottomPanel: scope.tabbedPanel,
                    showBottomPanel: _panelVisible,
                    statusBar: const WorkbenchStatusBar(
                      leading: [
                        WorkbenchStatusBarItem(
                          icon: Symbols.info_rounded,
                          label: 'workbench_shell example',
                        ),
                      ],
                    ),
                  ),
                ),
              ),
            ),
          ),
        );
      },
    );
  }

  Widget? _buildSidebar(String sectionId) {
    switch (sectionId) {
      case 'explorer':
        return const _SidebarBodyPlaceholder(
          text: 'Explorer sidebar — host-supplied content lands here.',
        );
      case 'search':
        return const _SidebarBodyPlaceholder(
          text: 'Search sidebar — host-supplied content lands here.',
        );
      case 'buttons':
        return const _ButtonsReviewSidebar();
      case 'notifications':
        return _NotificationsDemoSidebar(service: _notificationService);
      case 'settings':
        return _SettingsSidebar(themeController: widget.themeController);
    }
    return null;
  }
}

/// Notifications demo sidebar — triggers cards through the
/// [NotificationService] so an operator can exercise every API
/// surface from the example app (SPEC §spec:notification-center verify criteria).
class _NotificationsDemoSidebar extends StatefulWidget {
  const _NotificationsDemoSidebar({required this.service});

  final NotificationService service;

  @override
  State<_NotificationsDemoSidebar> createState() =>
      _NotificationsDemoSidebarState();
}

class _NotificationsDemoSidebarState extends State<_NotificationsDemoSidebar> {
  int _counter = 0;

  /// Outstanding progress demos so cancel/cleanup can find them. Keyed
  /// by the controller's notification id.
  final Map<Object, _DemoProgressJob> _jobs = {};

  @override
  void dispose() {
    for (final job in _jobs.values) {
      job.dispose();
    }
    _jobs.clear();
    super.dispose();
  }

  void _show(NotificationSeverity severity, String message) {
    widget.service.show(severity: severity, message: '$message #${++_counter}');
  }

  void _showBurst(int count, NotificationSeverity severity) {
    for (var i = 0; i < count; i++) {
      _show(severity, severity.name);
    }
  }

  void _showOverflowMix() {
    widget.service.show(
      severity: NotificationSeverity.warning,
      message: 'Persistent warning — stays in the visible stack',
    );
    for (var i = 0; i < 5; i++) {
      _show(NotificationSeverity.info, 'Burst info');
    }
  }

  /// Determinate progress — ticks from 0 to 100 % over ~5 s, then
  /// converts the card to a 6 s success toast via
  /// `complete(successMessage:)`.
  void _showDeterminateProgress() {
    final controller = widget.service.showProgress(
      message: 'Saving project file…',
    );
    final job = _DemoProgressJob(controller);
    _jobs[controller.id] = job;
    job.runDeterminate(onDone: () => _jobs.remove(controller.id));
  }

  /// Indeterminate progress — runs until the demo timer elapses or the
  /// host's `dispose` cleans it up. Demonstrates the spinner variant.
  void _showIndeterminateProgress() {
    final controller = widget.service.showProgress(
      message: 'Refreshing index…',
    );
    final job = _DemoProgressJob(controller);
    _jobs[controller.id] = job;
    job.runIndeterminate(onDone: () => _jobs.remove(controller.id));
  }

  /// Cancellable indeterminate progress. When the operator presses
  /// Cancel, the demo observes the cancellation future and converts
  /// the card to a persistent error toast via `fail`.
  void _showCancellableProgress() {
    final controller = widget.service.showProgress(
      message: 'Long-running upload…',
      cancellable: true,
    );
    final job = _DemoProgressJob(controller);
    _jobs[controller.id] = job;
    job.runCancellable(onDone: () => _jobs.remove(controller.id));
  }

  void _showWithAction() {
    var dismissedId = Object();
    dismissedId = widget.service.show(
      severity: NotificationSeverity.info,
      message: 'Tap Undo to dismiss in the same frame',
      actions: [
        NotificationAction(
          label: 'Undo',
          onInvoke: () {
            // Demonstrate a follow-up notification — the action card
            // is dismissed by the host immediately, so a new card
            // here is the canonical pattern for "keep state visible".
            widget.service.show(
              severity: NotificationSeverity.success,
              message: 'Undone (action ran on #${dismissedId.hashCode})',
            );
          },
        ),
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    final theme = context.workbenchTheme;
    return SingleChildScrollView(
      padding: const EdgeInsets.all(WorkbenchLayoutConstants.spacingLg),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Text(
            'Notifications',
            style: theme.sectionTitle.copyWith(color: theme.foreground),
          ),
          const SizedBox(height: WorkbenchLayoutConstants.spacingSm),
          Text(
            'Cards anchor bottom-right. Info and success self-dismiss '
            'after 6 s; warning and error persist until dismissed. '
            'Hover or backgrounding the window pauses the timer.',
            style: theme.bodyText.copyWith(color: theme.descriptionForeground),
          ),
          const SizedBox(height: WorkbenchLayoutConstants.spacingLg),
          _DemoButton(
            label: 'Info',
            onTap: () => _show(NotificationSeverity.info, 'Info notice'),
          ),
          _DemoButton(
            label: 'Success',
            onTap: () => _show(NotificationSeverity.success, 'Saved'),
          ),
          _DemoButton(
            label: 'Warning',
            onTap: () => _show(NotificationSeverity.warning, 'Heads up'),
          ),
          _DemoButton(
            label: 'Error',
            onTap: () => _show(NotificationSeverity.error, 'Failed'),
          ),
          const SizedBox(height: WorkbenchLayoutConstants.spacingLg),
          _DemoButton(
            label: 'Burst: 3 info (stack)',
            onTap: () => _showBurst(3, NotificationSeverity.info),
          ),
          _DemoButton(
            label: 'Burst: 7 info (overflow)',
            onTap: () => _showBurst(7, NotificationSeverity.info),
          ),
          _DemoButton(label: 'Mix: warning + 5 info', onTap: _showOverflowMix),
          const SizedBox(height: WorkbenchLayoutConstants.spacingLg),
          _DemoButton(label: 'With action button', onTap: _showWithAction),
          const SizedBox(height: WorkbenchLayoutConstants.spacingLg),
          _DemoButton(
            label: 'Progress: determinate (5 s → success)',
            onTap: _showDeterminateProgress,
          ),
          _DemoButton(
            label: 'Progress: indeterminate (4 s → success)',
            onTap: _showIndeterminateProgress,
          ),
          _DemoButton(
            label: 'Progress: cancellable (cancel → error)',
            onTap: _showCancellableProgress,
          ),
          const SizedBox(height: WorkbenchLayoutConstants.spacingLg),
          _DemoButton(label: 'Clear all', onTap: widget.service.clear),
        ],
      ),
    );
  }
}

/// Compact button used by the notifications demo sidebar. Renders the
/// themed secondary tier ([FilledButton.tonal]); `applyWorkbenchChrome`
/// supplies its neutral fill, label, and 4px shape — no hand-rolled
/// chrome here (§spec:chrome-material-theming).
class _DemoButton extends StatelessWidget {
  const _DemoButton({required this.label, required this.onTap});
  final String label;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(
        vertical: WorkbenchLayoutConstants.spacingXxs,
      ),
      child: FilledButton.tonal(onPressed: onTap, child: Text(label)),
    );
  }
}

/// Settings sidebar — exposes the brightness-paired theme controls
/// driven by [WorkbenchThemeController], mirroring VS Code's own
/// settings layout: an "Auto detect color scheme" checkbox plus three
/// dropdown fields ("Color theme", "Preferred dark color theme",
/// "Preferred light color theme"). When auto-detect is on, the
/// "Color theme" field is disabled and the active theme is paired to
/// system brightness via the two preferred slots; when off, the
/// active theme is whatever the "Color theme" dropdown picks.
class _SettingsSidebar extends StatelessWidget {
  const _SettingsSidebar({required this.themeController});

  final WorkbenchThemeController themeController;

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: themeController,
      builder: (context, _) {
        final isSystem = themeController.themeMode == ThemeMode.system;
        final lightThemes = themeController.availableThemes
            .where((e) => e.brightness == Brightness.light)
            .toList();
        final darkThemes = themeController.availableThemes
            .where((e) => e.brightness == Brightness.dark)
            .toList();
        return SingleChildScrollView(
          padding: const EdgeInsets.all(WorkbenchLayoutConstants.spacingLg),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              _AutoDetectToggle(
                isOn: isSystem,
                onChanged: (next) {
                  if (next) {
                    themeController.themeMode = ThemeMode.system;
                  } else {
                    // Pin to whichever brightness is currently
                    // displayed so flipping auto-detect off doesn't
                    // visibly change the active theme.
                    themeController.themeMode =
                        themeController.brightness == Brightness.light
                        ? ThemeMode.light
                        : ThemeMode.dark;
                  }
                },
              ),
              const SizedBox(height: WorkbenchLayoutConstants.spacingXl),
              _ThemeDropdownField(
                label: 'Color theme',
                description:
                    'Specifies the color theme used in the workbench '
                    'when "Auto detect color scheme" is off.',
                entries: themeController.availableThemes,
                value: themeController.selectedFilename,
                enabled: !isSystem,
                onChanged: (filename) {
                  if (filename != null) {
                    themeController.selectTheme(filename);
                  }
                },
              ),
              const SizedBox(height: WorkbenchLayoutConstants.spacingXl),
              _ThemeDropdownField(
                label: 'Preferred dark color theme',
                description:
                    'Used when the system is in dark mode and '
                    '"Auto detect color scheme" is on.',
                entries: darkThemes,
                value: themeController.preferredDark,
                enabled: true,
                onChanged: (filename) {
                  if (filename != null) {
                    themeController.preferredDark = filename;
                  }
                },
              ),
              const SizedBox(height: WorkbenchLayoutConstants.spacingXl),
              _ThemeDropdownField(
                label: 'Preferred light color theme',
                description:
                    'Used when the system is in light mode and '
                    '"Auto detect color scheme" is on.',
                entries: lightThemes,
                value: themeController.preferredLight,
                enabled: true,
                onChanged: (filename) {
                  if (filename != null) {
                    themeController.preferredLight = filename;
                  }
                },
              ),
            ],
          ),
        );
      },
    );
  }
}

/// "Auto detect color scheme" checkbox row. Toggles
/// [WorkbenchThemeController.themeMode] between [ThemeMode.system] and
/// the manual slot matching whichever brightness is currently
/// displayed, so flipping the checkbox doesn't visibly swap the theme
/// the user is looking at.
class _AutoDetectToggle extends StatelessWidget {
  const _AutoDetectToggle({required this.isOn, required this.onChanged});

  final bool isOn;
  final ValueChanged<bool> onChanged;

  @override
  Widget build(BuildContext context) {
    final theme = context.workbenchTheme;
    return InkWell(
      onTap: () => onChanged(!isOn),
      child: Padding(
        padding: const EdgeInsets.symmetric(
          vertical: WorkbenchLayoutConstants.spacingSm,
        ),
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Icon(
              isOn ? Symbols.check_box : Symbols.check_box_outline_blank,
              size: 18,
              fill: isOn ? 1 : 0,
              color: isOn
                  ? theme.tabBarIndicatorColor
                  : theme.descriptionForeground,
            ),
            const SizedBox(width: WorkbenchLayoutConstants.spacingSm),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    'Auto detect color scheme',
                    style: theme.bodyStyle.copyWith(
                      color: theme.foreground,
                      fontWeight: FontWeight.w600,
                    ),
                  ),
                  const SizedBox(height: WorkbenchLayoutConstants.spacingXxs),
                  Text(
                    'Automatically select a color theme based on the '
                    'system color mode.',
                    style: theme.bodyStyle.copyWith(
                      color: theme.descriptionForeground,
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// Labelled dropdown for a single theme slot. Disabled state greys
/// out the control and ignores taps — used for "Color theme" while
/// auto-detect is on, where the active theme is derived from the
/// preferred slots rather than picked directly.
class _ThemeDropdownField extends StatelessWidget {
  const _ThemeDropdownField({
    required this.label,
    required this.description,
    required this.entries,
    required this.value,
    required this.enabled,
    required this.onChanged,
  });

  final String label;
  final String description;
  final List<WorkbenchThemeEntry> entries;
  final String value;
  final bool enabled;
  final ValueChanged<String?> onChanged;

  @override
  Widget build(BuildContext context) {
    final theme = context.workbenchTheme;
    final labelColor = enabled
        ? theme.foreground
        : theme.foreground.withValues(alpha: 0.55);
    final descriptionColor = enabled
        ? theme.descriptionForeground
        : theme.descriptionForeground.withValues(alpha: 0.55);
    final itemColor = enabled
        ? theme.foreground
        : theme.foreground.withValues(alpha: 0.55);
    // Defensive: if the supplied value isn't in the entry list (a
    // stored preference for a theme that's no longer bundled), pass
    // null to the dropdown so it doesn't assert. The user can pick a
    // new value from the menu to re-seed the slot.
    final resolvedValue = entries.any((e) => e.filename == value)
        ? value
        : null;
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // Form label — settings dropdown caption follows the §spec:chrome-typography-canon
        // `labelText` tier (13 / w500), not the pane-header tier
        // that `sectionTitle` reserves for sidebar/panel grouping.
        Text(label, style: theme.labelText.copyWith(color: labelColor)),
        const SizedBox(height: WorkbenchLayoutConstants.spacingXxs),
        Text(
          description,
          style: theme.bodyStyle.copyWith(color: descriptionColor),
        ),
        const SizedBox(height: WorkbenchLayoutConstants.spacingSm),
        Container(
          decoration: BoxDecoration(
            color: theme.inputBackground,
            borderRadius: WorkbenchLayoutConstants.containerRadius,
            border: Border.all(color: theme.inputBorder),
          ),
          padding: const EdgeInsets.symmetric(
            horizontal: WorkbenchLayoutConstants.spacingSm,
          ),
          child: DropdownButtonHideUnderline(
            child: DropdownButton<String>(
              value: resolvedValue,
              isExpanded: true,
              isDense: true,
              dropdownColor: theme.dropdownBackground,
              style: theme.bodyStyle.copyWith(color: itemColor),
              iconEnabledColor: theme.descriptionForeground,
              iconDisabledColor: theme.descriptionForeground.withValues(
                alpha: 0.5,
              ),
              items: [
                for (final entry in entries)
                  DropdownMenuItem<String>(
                    value: entry.filename,
                    child: Text(
                      entry.label,
                      style: theme.bodyStyle.copyWith(color: theme.foreground),
                    ),
                  ),
              ],
              onChanged: enabled ? onChanged : null,
            ),
          ),
        ),
      ],
    );
  }
}

class _EditorPlaceholder extends StatelessWidget {
  const _EditorPlaceholder();

  @override
  Widget build(BuildContext context) {
    final theme = context.workbenchTheme;
    return Center(
      child: Text(
        'Editor area',
        style: theme.bodyStyle.copyWith(color: theme.descriptionForeground),
      ),
    );
  }
}

class _SidebarBodyPlaceholder extends StatelessWidget {
  const _SidebarBodyPlaceholder({required this.text});
  final String text;

  @override
  Widget build(BuildContext context) {
    final theme = context.workbenchTheme;
    return Padding(
      padding: const EdgeInsets.all(WorkbenchLayoutConstants.spacingLg),
      child: Text(text, style: theme.bodyStyle),
    );
  }
}

/// VS Code's three button tiers rendered through the chrome helper —
/// `applyWorkbenchChrome` themes [FilledButton] (primary),
/// [FilledButton.tonal] (secondary), and [TextButton] (text/link),
/// each flat at rest with VS Code's rectangular 4px corners
/// ([WorkbenchLayoutConstants.buttonShape]). Without the helper these
/// would render Material 3's accent/secondary-container colors and the
/// default pill ([StadiumBorder]). This is the self-contained chrome
/// review surface (SPEC §spec:chrome-material-theming): run the example standalone to review the
/// button taxonomy, no host needed.
class _ButtonsReviewSidebar extends StatelessWidget {
  const _ButtonsReviewSidebar();

  @override
  Widget build(BuildContext context) {
    final theme = context.workbenchTheme;
    return SingleChildScrollView(
      padding: const EdgeInsets.all(WorkbenchLayoutConstants.spacingLg),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Text(
            'VS Code buttons',
            style: theme.sectionTitle.copyWith(color: theme.foreground),
          ),
          const SizedBox(height: WorkbenchLayoutConstants.spacingSm),
          Text(
            'The three VS Code button tiers, themed by applyWorkbenchChrome: '
            'a primary (accent fill), a secondary (neutral fill), and a '
            'text/link button. All flat at rest with VS Code\'s 4px '
            'rectangle, not Material 3\'s pill — none casts an at-rest shadow.',
            style: theme.bodyText.copyWith(color: theme.descriptionForeground),
          ),
          const SizedBox(height: WorkbenchLayoutConstants.spacingLg),
          FilledButton(onPressed: () {}, child: const Text('Primary')),
          const SizedBox(height: WorkbenchLayoutConstants.spacingSm),
          FilledButton.tonal(onPressed: () {}, child: const Text('Secondary')),
          const SizedBox(height: WorkbenchLayoutConstants.spacingSm),
          TextButton(onPressed: () {}, child: const Text('Text / link')),
        ],
      ),
    );
  }
}

class _PanelBodyPlaceholder extends StatelessWidget {
  const _PanelBodyPlaceholder({required this.panel});
  final ExamplePanel panel;

  @override
  Widget build(BuildContext context) {
    final theme = context.workbenchTheme;
    return Padding(
      padding: const EdgeInsets.all(WorkbenchLayoutConstants.spacingLg),
      child: Text(
        '${panel.label} tab — host-supplied content lands here.',
        style: theme.bodyStyle,
      ),
    );
  }
}

/// Output panel content — increments a counter once per second while
/// the panel is focused, pauses while blurred. Demonstrates the
/// canonical [PanelLifecycle] pattern for pub.dev consumers.
class _OutputCounterBody extends StatefulWidget {
  const _OutputCounterBody({required this.lifecycle});
  final PanelLifecycle lifecycle;

  @override
  State<_OutputCounterBody> createState() => _OutputCounterBodyState();
}

class _OutputCounterBodyState extends State<_OutputCounterBody> {
  Timer? _timer;
  int _count = 0;

  @override
  void initState() {
    super.initState();
    widget.lifecycle.isFocused.addListener(_handleFocusChanged);
    if (widget.lifecycle.isFocused.value) _startTimer();
  }

  @override
  void dispose() {
    widget.lifecycle.isFocused.removeListener(_handleFocusChanged);
    _timer?.cancel();
    super.dispose();
  }

  void _handleFocusChanged() {
    if (widget.lifecycle.isFocused.value) {
      _startTimer();
    } else {
      _timer?.cancel();
      _timer = null;
    }
  }

  void _startTimer() {
    _timer ??= Timer.periodic(const Duration(seconds: 1), (_) {
      if (!mounted) return;
      setState(() => _count++);
    });
  }

  @override
  Widget build(BuildContext context) {
    final theme = context.workbenchTheme;
    return Padding(
      padding: const EdgeInsets.all(WorkbenchLayoutConstants.spacingLg),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            'Output tab — increments once per second while focused.',
            style: theme.bodyStyle,
          ),
          const SizedBox(height: WorkbenchLayoutConstants.spacingMd),
          Text(
            // Value readout — `valueText` is the §spec:editor-derived-surfaces editor-derived
            // numeric tier (tabular monospace at editor size). Earlier
            // drafts reached for `sectionTitle`, which the canon now
            // reserves for sidebar/panel pane headers.
            'Counter: $_count',
            style: theme.valueText.copyWith(color: theme.foreground),
          ),
        ],
      ),
    );
  }
}

/// Bookkeeping for an in-flight notification progress demo. Owns a
/// timer plus subscriptions so the sidebar can clean up if the
/// operator hits Clear All or navigates away mid-run.
class _DemoProgressJob {
  _DemoProgressJob(this.controller);

  final NotificationProgressController controller;
  Timer? _timer;

  /// Tick from 0 to 100 % over ~5 s, then complete with a success
  /// message.
  void runDeterminate({required VoidCallback onDone}) {
    var step = 0;
    const total = 20;
    _timer = Timer.periodic(const Duration(milliseconds: 250), (timer) {
      if (!controller.isActive) {
        timer.cancel();
        onDone();
        return;
      }
      step += 1;
      final value = step / total;
      controller.report(
        progress: value,
        message: 'Saving project file… ${(value * 100).round()} %',
      );
      if (step >= total) {
        timer.cancel();
        controller.complete(successMessage: 'Project file saved.');
        onDone();
      }
    });
  }

  /// Indeterminate spinner for ~4 s, then complete with a success
  /// message.
  void runIndeterminate({required VoidCallback onDone}) {
    _timer = Timer(const Duration(seconds: 4), () {
      if (!controller.isActive) {
        onDone();
        return;
      }
      controller.complete(successMessage: 'Index refreshed.');
      onDone();
    });
  }

  /// Cancellable indeterminate job — waits for the operator to press
  /// Cancel. On cancel, terminates with a persistent error toast so
  /// the failure is visible.
  void runCancellable({required VoidCallback onDone}) {
    controller.cancellation.then((_) {
      if (!controller.isActive) {
        onDone();
        return;
      }
      controller.fail('Upload cancelled.');
      onDone();
    });
  }

  void dispose() {
    _timer?.cancel();
    _timer = null;
    if (controller.isActive) {
      controller.complete();
    }
  }
}
0
likes
150
points
156
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

VS Code-style workbench layout shell for Flutter with activity bar, sidebar, editor area, tabbed bottom panel, status bar, menu bar, and structural primitives. Depends only on Flutter.

Repository (GitHub)
View/report issues
Contributing

Topics

#workbench #vscode #ide #layout #desktop

License

BSD-3-Clause (license)

Dependencies

equatable, flutter, material_color_utilities, material_symbols_icons

More

Packages that depend on workbench_shell