The Garage SDKs, in the open
garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme, docs under docs/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
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<GarageShell> createState() => _GarageShellState();
|
||||
}
|
||||
|
||||
class _GarageShellState extends State<GarageShell> {
|
||||
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!,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user