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
309 lines
9.0 KiB
Dart
309 lines
9.0 KiB
Dart
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!,
|
|
],
|
|
);
|
|
}
|
|
}
|