import "package:flutter/widgets.dart"; import "panel.dart"; import "theme/garage_theme.dart"; /// The reusable desktop editor frame. /// /// Applications provide the content for each slot. The frame owns the /// geometry and visual separation between the header, workspace, docks, and /// footer, so an editor can keep its app-specific controls outside GarageUI. class EditorShell extends StatelessWidget { const EditorShell({ super.key, required this.center, this.header, this.footer, this.left, this.right, this.leftWidth = 0, this.rightWidth = 0, this.gap = 1, this.backgroundColor, }); final Widget center; final Widget? header; final Widget? footer; final Widget? left; final Widget? right; final double leftWidth; final double rightWidth; final double gap; final Color? backgroundColor; @override Widget build(BuildContext context) { final theme = GarageTheme.of(context); final divider = theme.colorScheme.divider; Widget dock(Widget child, double width) => SizedBox(width: width, child: child); Widget separator() => ColoredBox( color: divider, child: SizedBox(width: gap), ); final workspace = Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (left != null && leftWidth > 0) ...[ dock(left!, leftWidth), separator(), ], Expanded(child: center), if (right != null && rightWidth > 0) ...[ separator(), dock(right!, rightWidth), ], ], ); return ColoredBox( color: backgroundColor ?? theme.colorScheme.background, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (header != null) header!, Expanded(child: workspace), if (footer != null) footer!, ], ), ); } } /// Shared surface treatment for an editor's top and bottom chrome. class ChromeBar extends StatelessWidget implements PreferredSizeWidget { const ChromeBar({ super.key, required this.child, this.height, this.padding = EdgeInsets.zero, }); final Widget child; /// Leave null to take [Density.chromeBarHeight], which is what keeps the /// bar in step with the controls inside it. Was a hardcoded 30 that didnt /// move with the tier at all, so at product density the bar came out /// shorter than its own buttons. final double? height; final EdgeInsetsGeometry padding; /// Without a BuildContext theres no density to ask, so this can only report /// an explicit height. Nothing reads it today; the fallback matches the way /// surface.dart's dividers handle the same problem. @override Size get preferredSize => Size.fromHeight(height ?? 0); @override Widget build(BuildContext context) { final theme = GarageTheme.of(context); return SizedBox( height: height ?? theme.density.chromeBarHeight, child: DecoratedBox( decoration: BoxDecoration(color: theme.colorScheme.chrome), child: Padding(padding: padding, child: child), ), ); } } /// A dock surface with optional package-owned header and footer slots. class DockPanel extends StatelessWidget { const DockPanel({ super.key, required this.child, this.header, this.footer, this.padding = EdgeInsets.zero, }); final Widget child; final Widget? header; final Widget? footer; final EdgeInsetsGeometry padding; @override Widget build(BuildContext context) { return Panel( borderRadius: 0, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (header != null) header!, Expanded( child: Padding(padding: padding, child: child), ), if (footer != null) footer!, ], ), ); } } /// Garage editor layout: a main surface and two vertically stacked /// sidebar surfaces separated by a draggable divider. /// /// The shell owns the complete editor frame: header, workspace, footer, sizing, /// hover highlighting, and the divider interaction. The application supplies /// the header/footer content and the canvas, explorer, and properties widgets. class GarageShell extends StatefulWidget { const GarageShell({ super.key, required this.main, required this.sidebarTop, required this.sidebarBottom, this.header, this.footer, this.initialSidebarWidth = 320, this.minPanelWidth = 100, this.gap, this.handleHeight = 200, }); final Widget main; final Widget sidebarTop; final Widget sidebarBottom; final Widget? header; final Widget? footer; final double initialSidebarWidth; final double minPanelWidth; /// Null uses the active theme's [ThemeData.panelGap], matching the original /// Arcs & Angles Blender shell exactly. final double? gap; final double handleHeight; @override State createState() => _GarageShellState(); } class _GarageShellState extends State { late double _sidebarWidth = widget.initialSidebarWidth; int? _hoveredPanel; bool _dragging = false; void _resizeSidebar( DragUpdateDetails details, double availableWidth, double gap, ) { final overhead = gap * 3; final maxSidebarWidth = (availableWidth - overhead - widget.minPanelWidth) .clamp(widget.minPanelWidth, double.infinity); setState(() { _sidebarWidth = (_sidebarWidth - details.delta.dx).clamp( widget.minPanelWidth, maxSidebarWidth, ); }); } Widget _panel(int id, Widget child) { return MouseRegion( onEnter: (_) { if (_hoveredPanel != id) setState(() => _hoveredPanel = id); }, onExit: (_) { if (_hoveredPanel == id) setState(() => _hoveredPanel = null); }, child: Panel( active: _hoveredPanel == id, clipBehavior: Clip.hardEdge, child: child, ), ); } @override Widget build(BuildContext context) { final theme = GarageTheme.of(context); final scheme = theme.colorScheme; final gap = widget.gap ?? theme.panelGap; final workspace = ColoredBox( color: scheme.chrome, child: LayoutBuilder( builder: (context, constraints) { final sidebarWidth = _sidebarWidth; return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: Padding( padding: EdgeInsets.only(left: gap), child: _panel(0, widget.main), ), ), SizedBox( width: gap, child: Align( alignment: Alignment.center, child: SizedBox( height: widget.handleHeight, child: MouseRegion( cursor: SystemMouseCursors.resizeColumn, child: GestureDetector( // pane splitter, not a control - keep it out of the // semantics tree so it doesn't read as a bogus scrollable excludeFromSemantics: true, behavior: HitTestBehavior.translucent, onHorizontalDragStart: (_) => setState(() => _dragging = true), onHorizontalDragUpdate: (details) => _resizeSidebar(details, constraints.maxWidth, gap), onHorizontalDragEnd: (_) => setState(() => _dragging = false), onHorizontalDragCancel: () => setState(() => _dragging = false), child: SizedBox( width: gap, child: Center( child: _dragging ? Container( width: 1.5, color: scheme.mutedForeground, ) : null, ), ), ), ), ), ), ), Padding( padding: EdgeInsets.only(right: gap), child: SizedBox( width: sidebarWidth, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded(child: _panel(1, widget.sidebarTop)), SizedBox(height: gap), Expanded(child: _panel(2, widget.sidebarBottom)), ], ), ), ), ], ); }, ), ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (widget.header != null) widget.header!, Expanded(child: workspace), if (widget.footer != null) widget.footer!, ], ); } }