From cddfb29908fc9421d87480af0ef2b044f6486e77 Mon Sep 17 00:00:00 2001 From: "khoa.nguyen" Date: Wed, 4 Feb 2026 14:48:25 +0700 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20Add=20cascade=20resize=20suppor?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/lib/cascade_demo.dart | 686 +++++++++++++++++++++++++++++++++ example/lib/main.dart | 138 ++++--- lib/src/ide_layout.dart | 8 + lib/src/pane_controller.dart | 224 ++++++++++- lib/src/pane_entry.dart | 43 +++ lib/src/resize_calculator.dart | 43 ++- test/cascade_resize_test.dart | 438 +++++++++++++++++++++ 7 files changed, 1509 insertions(+), 71 deletions(-) create mode 100644 example/lib/cascade_demo.dart create mode 100644 test/cascade_resize_test.dart diff --git a/example/lib/cascade_demo.dart b/example/lib/cascade_demo.dart new file mode 100644 index 0000000..3e937d5 --- /dev/null +++ b/example/lib/cascade_demo.dart @@ -0,0 +1,686 @@ +/// Cascade Resize Demo - IDE Layout +/// +/// Demonstrates cascade resize in a realistic IDE-like layout. +library; + +import 'package:flutter/material.dart'; +import 'package:panes/panes.dart'; + +class CascadeDemo extends StatefulWidget { + const CascadeDemo({super.key}); + + @override + State createState() => _CascadeDemoState(); +} + +class _CascadeDemoState extends State { + late PaneController _horizontalController; + late PaneController _verticalController; + + // Track dynamic entries for horizontal panes + late List _horizontalEntries; + int _editorCount = 2; + + @override + void initState() { + super.initState(); + _initEntries(); + _buildControllers(); + } + + void _initEntries() { + _horizontalEntries = [ + // File Explorer - eager, will cascade + PaneEntry( + id: 'explorer', + initialSize: PaneSize.pixel(200), + minSize: PaneSize.pixel(150), + maxSize: PaneSize.pixel(350), + resizeBehavior: ResizeBehavior.eager, + ), + // Editor 1 - eager (default for fraction), absorbs cascade + PaneEntry( + id: 'editor1', + initialSize: PaneSize.fraction(1.0), + minSize: PaneSize.pixel(150), + ), + // Editor 2 - eager, absorbs cascade + PaneEntry( + id: 'editor2', + initialSize: PaneSize.fraction(1.0), + minSize: PaneSize.pixel(150), + ), + // Outline Panel - FIXED, won't participate in cascade + PaneEntry( + id: 'outline', + initialSize: PaneSize.pixel(180), + minSize: PaneSize.pixel(120), + maxSize: PaneSize.pixel(300), + resizeBehavior: ResizeBehavior.fixed, + ), + ]; + _editorCount = 2; + } + + void _buildControllers() { + // Horizontal: Explorer | Editors... | Outline + _horizontalController = PaneController(entries: _horizontalEntries); + + // Vertical: Main Area / Terminal + _verticalController = PaneController( + entries: [ + // Main content area - fractional, eager + PaneEntry( + id: 'main', + initialSize: PaneSize.fraction(1.0), + minSize: PaneSize.pixel(200), + ), + // Terminal/Output - pixel, eager (can cascade) + PaneEntry( + id: 'terminal', + initialSize: PaneSize.pixel(150), + minSize: PaneSize.pixel(80), + maxSize: PaneSize.pixel(400), + resizeBehavior: ResizeBehavior.eager, + ), + ], + ); + } + + void _addEditor() { + setState(() { + _editorCount++; + final newEditor = PaneEntry( + id: 'editor$_editorCount', + initialSize: PaneSize.fraction(1.0), + minSize: PaneSize.pixel(150), + ); + // Insert before outline (last item) + _horizontalEntries.insert(_horizontalEntries.length - 1, newEditor); + _horizontalController.dispose(); + _horizontalController = PaneController(entries: _horizontalEntries); + }); + } + + void _removeLastEditor() { + // Keep at least one editor + final editorCount = _horizontalEntries + .where((e) => e.id.startsWith('editor')) + .length; + if (editorCount <= 1) return; + + setState(() { + // Find and remove the last editor (before outline) + final lastEditorIndex = _horizontalEntries.length - 2; + if (_horizontalEntries[lastEditorIndex].id.startsWith('editor')) { + _horizontalEntries.removeAt(lastEditorIndex); + _horizontalController.dispose(); + _horizontalController = PaneController(entries: _horizontalEntries); + } + }); + } + + @override + void dispose() { + _horizontalController.dispose(); + _verticalController.dispose(); + super.dispose(); + } + + void _reset() { + setState(() { + _horizontalController.dispose(); + _verticalController.dispose(); + _initEntries(); + _buildControllers(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF1e1e1e), + body: Column( + children: [ + // Title bar + _buildTitleBar(), + // Instructions + _buildInstructions(), + // Size indicators for horizontal panels + _buildSizeIndicators(), + // Main IDE layout (vertical split: main area + terminal) + Expanded( + child: Padding( + padding: const EdgeInsets.all(4), + child: MultiPane( + controller: _verticalController, + direction: Axis.vertical, + paneBuilder: (context, id) => _buildVerticalPane(id), + ), + ), + ), + // Size indicator for terminal + _buildTerminalSizeIndicator(), + ], + ), + ); + } + + Widget _buildVerticalPane(String id) { + return switch (id) { + 'main' => MultiPane( + controller: _horizontalController, + direction: Axis.horizontal, + paneBuilder: (context, id) => _buildHorizontalPane(id), + ), + 'terminal' => _buildTerminalPanel(), + _ => const SizedBox(), + }; + } + + Widget _buildTitleBar() { + return Container( + height: 40, + color: const Color(0xFF323233), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, size: 20), + color: Colors.white70, + onPressed: () => Navigator.pop(context), + ), + const Text( + 'Cascade Resize Demo - IDE Layout', + style: TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: _reset, + icon: const Icon(Icons.refresh, size: 16), + label: const Text('Reset'), + style: TextButton.styleFrom(foregroundColor: Colors.white70), + ), + ], + ), + ); + } + + Widget _buildInstructions() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + color: const Color(0xFF2d2d30), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Try: Drag Explorer panel past its max (350px)', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Editor panels (eager) will shrink • Outline panel (fixed) stays unchanged', + style: TextStyle(color: Colors.grey[400], fontSize: 12), + ), + ], + ), + ), + const SizedBox(width: 16), + _legendItem('eager', Colors.green), + const SizedBox(width: 12), + _legendItem('fixed', Colors.red), + ], + ), + ); + } + + Widget _legendItem(String label, Color color) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 4), + Text(label, style: TextStyle(color: Colors.grey[400], fontSize: 11)), + ], + ); + } + + Widget _buildSizeIndicators() { + return ListenableBuilder( + listenable: _horizontalController, + builder: (context, _) { + return Container( + height: 32, + color: const Color(0xFF252526), + child: Row( + children: [ + for (final entry in _horizontalController.entries) + Expanded(child: _sizeChip(entry, _horizontalController)), + ], + ), + ); + }, + ); + } + + Widget _buildTerminalSizeIndicator() { + return ListenableBuilder( + listenable: _verticalController, + builder: (context, _) { + final entry = _verticalController.entries + .firstWhere((e) => e.id == 'terminal'); + return Container( + height: 24, + color: const Color(0xFF252526), + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Expanded(child: _sizeChip(entry, _verticalController)), + ], + ), + ); + }, + ); + } + + Widget _sizeChip(PaneEntry entry, PaneController controller) { + final size = controller.getPixelSize(entry.id) ?? + controller.getFractionalSize(entry.id); + final behavior = entry.effectiveResizeBehavior; + final color = behavior == ResizeBehavior.fixed ? Colors.red : Colors.green; + + final sizeText = controller.getPixelSize(entry.id) != null + ? '${size!.toInt()}px' + : controller.getFractionalSize(entry.id) != null + ? 'flex: ${size!.toStringAsFixed(2)}' + : entry.initialSize is PaneSizePixel + ? '${entry.initialSize.size.toInt()}px' + : 'flex'; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _getPaneName(entry.id), + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 6), + Text( + sizeText, + style: const TextStyle( + color: Colors.white70, + fontSize: 10, + fontFamily: 'monospace', + ), + ), + ], + ), + ); + } + + String _getPaneName(String id) { + if (id.startsWith('editor')) { + final num = id.replaceFirst('editor', ''); + return 'Editor $num'; + } + return switch (id) { + 'explorer' => 'Explorer', + 'outline' => 'Outline', + 'terminal' => 'Terminal', + 'main' => 'Main', + _ => id, + }; + } + + Widget _buildHorizontalPane(String id) { + if (id.startsWith('editor')) { + final num = int.tryParse(id.replaceFirst('editor', '')) ?? 0; + return _buildEditorPanel(num); + } + return switch (id) { + 'explorer' => _buildExplorerPanel(), + 'outline' => _buildOutlinePanel(), + _ => const SizedBox(), + }; + } + + Widget _buildExplorerPanel() { + final entry = + _horizontalController.entries.firstWhere((e) => e.id == 'explorer'); + return _panelContainer( + color: Colors.green, + header: 'EXPLORER', + behavior: entry.effectiveResizeBehavior, + constraints: 'min: 150 • max: 350', + child: ListView( + padding: const EdgeInsets.all(8), + children: [ + _fileItem(Icons.folder, 'lib', isFolder: true), + _fileItem(Icons.insert_drive_file, ' main.dart'), + _fileItem(Icons.insert_drive_file, ' app.dart'), + _fileItem(Icons.folder, 'test', isFolder: true), + _fileItem(Icons.insert_drive_file, ' widget_test.dart'), + _fileItem(Icons.description, 'pubspec.yaml'), + _fileItem(Icons.description, 'README.md'), + ], + ), + ); + } + + Widget _buildEditorPanel(int index) { + final id = 'editor$index'; + final entry = _horizontalController.entries + .where((e) => e.id == id) + .firstOrNull; + if (entry == null) return const SizedBox(); + + return _panelContainer( + color: Colors.green, + header: 'EDITOR $index', + behavior: entry.effectiveResizeBehavior, + constraints: 'min: 150 • flex', + child: Container( + color: const Color(0xFF1e1e1e), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 1; i <= 15; i++) + Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Row( + children: [ + SizedBox( + width: 30, + child: Text( + '$i', + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + fontFamily: 'monospace', + ), + ), + ), + Expanded( + child: Text( + i == 1 + ? 'import \'package:flutter/material.dart\';' + : i == 3 + ? 'void main() {' + : i == 4 + ? ' runApp(const MyApp());' + : i == 5 + ? '}' + : '', + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildOutlinePanel() { + final entry = + _horizontalController.entries.firstWhere((e) => e.id == 'outline'); + return _panelContainer( + color: Colors.red, + header: 'OUTLINE (FIXED)', + behavior: entry.effectiveResizeBehavior, + constraints: 'min: 120 • max: 300', + child: ListView( + padding: const EdgeInsets.all(8), + children: [ + _outlineItem('MyApp', Icons.class_, 0), + _outlineItem('build()', Icons.functions, 1), + _outlineItem('HomePage', Icons.class_, 0), + _outlineItem('_HomePageState', Icons.class_, 1), + _outlineItem('initState()', Icons.functions, 2), + _outlineItem('build()', Icons.functions, 2), + ], + ), + ); + } + + Widget _buildTerminalPanel() { + final entry = + _verticalController.entries.firstWhere((e) => e.id == 'terminal'); + final editorCount = + _horizontalEntries.where((e) => e.id.startsWith('editor')).length; + + return _panelContainer( + color: Colors.green, + header: 'TERMINAL', + behavior: entry.effectiveResizeBehavior, + constraints: 'min: 80 • max: 400', + child: Container( + color: const Color(0xFF1e1e1e), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Dynamic panel controls + Row( + children: [ + Text( + '\$ ', + style: TextStyle( + color: Colors.green[300], + fontSize: 12, + fontFamily: 'monospace', + ), + ), + _terminalButton( + icon: Icons.add, + label: 'Add Editor', + onPressed: _addEditor, + ), + const SizedBox(width: 8), + _terminalButton( + icon: Icons.remove, + label: 'Remove Editor', + onPressed: editorCount > 1 ? _removeLastEditor : null, + ), + const SizedBox(width: 16), + Text( + '($editorCount editors)', + style: TextStyle( + color: Colors.grey[500], + fontSize: 11, + fontFamily: 'monospace', + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '\$ flutter run', + style: TextStyle( + color: Colors.green[300], + fontSize: 12, + fontFamily: 'monospace', + ), + ), + const SizedBox(height: 4), + const Text( + 'Launching lib/main.dart on macOS in debug mode...', + style: TextStyle( + color: Colors.white70, + fontSize: 12, + fontFamily: 'monospace', + ), + ), + const SizedBox(height: 4), + Text( + '✓ Built build/macos/Build/Products/Debug/example.app', + style: TextStyle( + color: Colors.green[300], + fontSize: 12, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + ); + } + + Widget _terminalButton({ + required IconData icon, + required String label, + VoidCallback? onPressed, + }) { + return TextButton.icon( + onPressed: onPressed, + style: TextButton.styleFrom( + foregroundColor: Colors.cyan[300], + disabledForegroundColor: Colors.grey[700], + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: Icon(icon, size: 14), + label: Text(label, style: const TextStyle(fontSize: 11)), + ); + } + + Widget _panelContainer({ + required Color color, + required String header, + required ResizeBehavior behavior, + required String constraints, + required Widget child, + }) { + return Container( + decoration: BoxDecoration( + color: const Color(0xFF252526), + border: Border.all(color: color.withValues(alpha: 0.3)), + borderRadius: BorderRadius.circular(4), + ), + child: Column( + children: [ + Container( + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), + ), + child: Row( + children: [ + Text( + header, + style: TextStyle( + color: color, + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(2), + ), + child: Text( + behavior.name, + style: TextStyle(color: color, fontSize: 9), + ), + ), + const Spacer(), + Text( + constraints, + style: TextStyle( + color: Colors.grey[500], + fontSize: 9, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + Expanded(child: child), + ], + ), + ); + } + + Widget _fileItem(IconData icon, String name, {bool isFolder = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Icon( + icon, + size: 16, + color: isFolder ? Colors.amber : Colors.blue[300], + ), + const SizedBox(width: 6), + Text( + name, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ), + ); + } + + Widget _outlineItem(String name, IconData icon, int depth) { + return Padding( + padding: EdgeInsets.only(left: depth * 12.0, top: 2, bottom: 2), + child: Row( + children: [ + Icon(icon, size: 14, color: Colors.purple[300]), + const SizedBox(width: 6), + Expanded( + child: Text( + name, + style: const TextStyle(color: Colors.white70, fontSize: 11), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 8538829..3ccd252 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -65,6 +65,8 @@ library; import 'package:flutter/material.dart'; import 'package:panes/panes.dart'; +import 'cascade_demo.dart'; + void main() { runApp(const MyApp()); } @@ -162,9 +164,19 @@ class _IdeExampleState extends State { void initState() { super.initState(); _ideController = IdeController( + leftSize: PaneSize.pixel(250), + leftMinSize: PaneSize.pixel(150), leftMaxSize: PaneSize.pixel(500), + leftResizeBehavior: ResizeBehavior.eager, + rightSize: PaneSize.pixel(300), + rightMinSize: PaneSize.pixel(150), rightMaxSize: PaneSize.pixel(500), - bottomMaxSize: PaneSize.pixel(480), + rightResizeBehavior: ResizeBehavior.eager, + bottomSize: PaneSize.fraction(0.5), + bottomMinSize: PaneSize.pixel(50), + bottomAutoHide: true, + bottomAutoHideThreshold: PaneSize.fraction(0.5), + bottomResizeBehavior: ResizeBehavior.eager, ); // Show panels by default _ideController.rootController.show(IdePane.right.id); @@ -239,8 +251,7 @@ class _IdeExampleState extends State { // which pane ID is set in each controller if (isMaximized) { final centerMaxId = _ideController - .centerController - .maximizedPaneId; + .centerController.maximizedPaneId; if (centerMaxId == IdePane.bottom.id) { _isTerminalMaximized = true; _isEditorMaximized = false; @@ -348,6 +359,15 @@ class _IdeExampleState extends State { ), ), ), + // Cascade demo button + _titleBarAction( + Icons.swap_horiz, + 'Cascade Resize Demo', + () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const CascadeDemo()), + ), + ), + const SizedBox(width: 8), // Toggle buttons for panels (right side) _titleBarAction( Icons.terminal, @@ -1047,68 +1067,68 @@ class _IdeExampleState extends State { } Widget _keyword(String text) => Text( - text, - style: const TextStyle( - color: IdeColors.keyword, - fontSize: 13, - fontFamily: 'JetBrains Mono', - height: 1.5, - ), - ); + text, + style: const TextStyle( + color: IdeColors.keyword, + fontSize: 13, + fontFamily: 'JetBrains Mono', + height: 1.5, + ), + ); Widget _string(String text) => Text( - text, - style: const TextStyle( - color: IdeColors.string, - fontSize: 13, - fontFamily: 'JetBrains Mono', - height: 1.5, - ), - ); + text, + style: const TextStyle( + color: IdeColors.string, + fontSize: 13, + fontFamily: 'JetBrains Mono', + height: 1.5, + ), + ); Widget _comment(String text) => Text( - text, - style: const TextStyle( - color: IdeColors.comment, - fontSize: 13, - fontFamily: 'JetBrains Mono', - height: 1.5, - ), - ); + text, + style: const TextStyle( + color: IdeColors.comment, + fontSize: 13, + fontFamily: 'JetBrains Mono', + height: 1.5, + ), + ); Widget _function(String text) => Text( - text, - style: const TextStyle( - color: IdeColors.function, - fontSize: 13, - fontFamily: 'JetBrains Mono', - height: 1.5, - ), - ); + text, + style: const TextStyle( + color: IdeColors.function, + fontSize: 13, + fontFamily: 'JetBrains Mono', + height: 1.5, + ), + ); Widget _type(String text) => Text( - text, - style: const TextStyle( - color: IdeColors.type, - fontSize: 13, - fontFamily: 'JetBrains Mono', - height: 1.5, - ), - ); + text, + style: const TextStyle( + color: IdeColors.type, + fontSize: 13, + fontFamily: 'JetBrains Mono', + height: 1.5, + ), + ); Widget _variable(String text) => Text( - text, - style: const TextStyle( - color: IdeColors.variable, - fontSize: 13, - fontFamily: 'JetBrains Mono', - height: 1.5, - ), - ); + text, + style: const TextStyle( + color: IdeColors.variable, + fontSize: 13, + fontFamily: 'JetBrains Mono', + height: 1.5, + ), + ); Widget _plain(String text) => Text( - text, - style: const TextStyle( - color: IdeColors.text, - fontSize: 13, - fontFamily: 'JetBrains Mono', - height: 1.5, - ), - ); + text, + style: const TextStyle( + color: IdeColors.text, + fontSize: 13, + fontFamily: 'JetBrains Mono', + height: 1.5, + ), + ); Widget _buildRightPanel() { // Fleet style: outline panel with rounded corners diff --git a/lib/src/ide_layout.dart b/lib/src/ide_layout.dart index a223bef..f6a972d 100644 --- a/lib/src/ide_layout.dart +++ b/lib/src/ide_layout.dart @@ -4,6 +4,8 @@ import 'package:panes/src/pane_controller.dart'; import 'package:panes/src/pane_entry.dart'; import 'package:panes/src/pane_size.dart'; +export 'package:panes/src/pane_entry.dart' show ResizeBehavior; + /// Represents the different panes in an [IdeLayout]. enum IdePane { /// The left sidebar pane. @@ -51,6 +53,7 @@ class IdeController { bool leftAutoHide = true, PaneSize? leftAutoHideThreshold, bool leftVisible = true, + ResizeBehavior? leftResizeBehavior, // Right panel configuration PaneSize? rightSize, @@ -59,6 +62,7 @@ class IdeController { bool rightAutoHide = true, PaneSize? rightAutoHideThreshold, bool rightVisible = false, + ResizeBehavior? rightResizeBehavior, // Bottom panel configuration PaneSize? bottomSize, @@ -67,6 +71,7 @@ class IdeController { bool bottomAutoHide = true, PaneSize? bottomAutoHideThreshold, bool bottomVisible = false, + ResizeBehavior? bottomResizeBehavior, }) { // Horizontal: Left | Center | Right rootController = PaneController( @@ -79,6 +84,7 @@ class IdeController { autoHide: leftAutoHide, autoHideThreshold: leftAutoHideThreshold ?? PaneSize.fraction(0.5), visible: leftVisible, + resizeBehavior: leftResizeBehavior, ), PaneEntry( id: IdePane.centerContainer.id, @@ -92,6 +98,7 @@ class IdeController { autoHide: rightAutoHide, autoHideThreshold: rightAutoHideThreshold ?? PaneSize.fraction(0.5), visible: rightVisible, + resizeBehavior: rightResizeBehavior, ), ], ); @@ -111,6 +118,7 @@ class IdeController { autoHide: bottomAutoHide, autoHideThreshold: bottomAutoHideThreshold ?? PaneSize.fraction(0.5), visible: bottomVisible, + resizeBehavior: bottomResizeBehavior, ), ], ); diff --git a/lib/src/pane_controller.dart b/lib/src/pane_controller.dart index bfae3a9..9069919 100644 --- a/lib/src/pane_controller.dart +++ b/lib/src/pane_controller.dart @@ -145,12 +145,16 @@ class PaneController extends ChangeNotifier { /// /// Handles pixel panes, fractional panes, and auto-hide behavior. /// All constraint enforcement happens here. + /// + /// [resizerIndex] is optional and used for cascade resize operations. + /// If not provided, it will be calculated from [paneId] (resizer is after the pane). void resize({ required String paneId, required double delta, required double containerSize, required double resizerThickness, String? adjacentPaneId, + int? resizerIndex, }) { if (delta == 0) return; @@ -163,12 +167,23 @@ class PaneController extends ChangeNotifier { getCurrentFraction: (id) => _fractionalSizes[id], ); + // Calculate resizer index if not provided + // The resizer is positioned after the pane with paneId + final effectiveResizerIndex = + resizerIndex ?? _entries.indexWhere((e) => e.id == paneId); + // Determine if this is a pixel or fractional resize final isPixelPane = _pixelSizes[paneId] != null || entry.initialSize is PaneSizePixel; if (isPixelPane) { - _resizePixelPane(paneId, entry, delta, context); + _resizePixelPane( + paneId, + entry, + delta, + context, + resizerIndex: effectiveResizerIndex, + ); } else if (adjacentPaneId != null) { // Check if adjacent pane is pixel-sized final adjacentEntry = _getEntry(adjacentPaneId); @@ -176,8 +191,29 @@ class PaneController extends ChangeNotifier { adjacentEntry.initialSize is PaneSizePixel; if (isAdjacentPixel) { - // Resize the adjacent pixel pane with negative delta - _resizePixelPane(adjacentPaneId, adjacentEntry, -delta, context); + // When resizing a fractional pane adjacent to a pixel pane, + // we need to respect the fractional pane's min/max constraints. + // The fractional pane grows/shrinks opposite to the pixel pane. + final fractionalCurrentSize = + _getPixelSizeForCalculation(paneId) ?? + ResizeCalculator.toPixels(entry.initialSize, context); + final fractionalNewSize = fractionalCurrentSize + delta; + + final minSize = ResizeCalculator.getMinPixels(entry, context); + final maxSize = ResizeCalculator.getMaxPixels(entry, context); + final clampedFractionalSize = fractionalNewSize.clamp(minSize, maxSize); + final clampedDelta = clampedFractionalSize - fractionalCurrentSize; + + // Resize the adjacent pixel pane with the clamped negative delta + // Note: adjacent pane is AFTER the resizer, so cascade direction is flipped + _resizePixelPane( + adjacentPaneId, + adjacentEntry, + -clampedDelta, + context, + resizerIndex: effectiveResizerIndex, + isAfterResizer: true, + ); } else { // Both are fractional _resizeFractionalPanes( @@ -198,8 +234,10 @@ class PaneController extends ChangeNotifier { String id, PaneEntry entry, double delta, - ResizeContext context, - ) { + ResizeContext context, { + int? resizerIndex, + bool isAfterResizer = false, + }) { // Use virtual position if we're in overshoot/undershoot, otherwise actual size final currentSize = _maxOvershootPositions[id] ?? _minUndershootPositions[id] ?? @@ -212,17 +250,46 @@ class PaneController extends ChangeNotifier { // Handle max overshoot - track virtual position, clamp display to max if (requestedSize > maxSize) { + final overflow = requestedSize - maxSize; + + // Set the new size FIRST, then rebuild context for cascade _maxOvershootPositions[id] = requestedSize; _minUndershootPositions.remove(id); _pixelSizes[id] = maxSize; if (entry.autoHide) { _autoHideStates[id] = AutoHideVisible(pixelSize: maxSize); } + + // Try to cascade the overflow to other panes with UPDATED context + // NOTE: Fixed panes do NOT cascade - they just stop at their constraints + if (resizerIndex != null && + entry.effectiveResizeBehavior != ResizeBehavior.fixed) { + final updatedContext = ResizeCalculator.buildContext( + entries: _entries, + containerSize: context.containerSize, + resizerThickness: context.resizerThickness, + getCurrentPixelSize: _getPixelSizeForCalculation, + getCurrentFraction: (id) => _fractionalSizes[id], + ); + _cascadeResize( + resizerIndex: resizerIndex, + delta: overflow, + context: updatedContext, + // When pane is AFTER resizer, flip cascade direction + // (overflow needs to go to panes BEFORE resizer, not after) + flipDirection: isAfterResizer, + ); + } + return; } // Handle min undershoot for non-auto-hide panes // (auto-hide panes have their own below-min tracking) + // + // NOTE: We do NOT cascade on undershoot. When a pane hits its min, + // the resize stops. The "virtual" space below min doesn't actually + // exist, so there's nothing to distribute to other panes. if (!entry.autoHide && requestedSize < minSize) { _minUndershootPositions[id] = requestedSize; _maxOvershootPositions.remove(id); @@ -322,6 +389,153 @@ class PaneController extends ChangeNotifier { _fractionalSizes[id2] = newFrac2; } + // --------------------------------------------------------------------------- + // Cascade Resize + // --------------------------------------------------------------------------- + + /// Collects panes for cascade resize in the given direction. + /// + /// [resizerIndex] is the index of the resizer being dragged (0-based). + /// [forward] determines direction: true = higher indices, false = lower indices. + /// + /// Returns entries sorted by resize behavior priority: + /// 1. Eager panes first (absorb delta first) + /// 2. Reluctant panes last (absorb delta after eager exhausted) + /// 3. Fixed panes are excluded entirely + /// + /// IMPORTANT: Collection stops when encountering a fractional pane. + /// Fractional panes naturally handle redistribution through the flex layout, + /// so we don't cascade "through" them to reach panes on the other side. + List _collectCascadeTargets({ + required int resizerIndex, + required bool forward, + }) { + final List targets = []; + + if (forward) { + // Collect panes from resizer+1 to end + for (int i = resizerIndex + 1; i < _entries.length; i++) { + final entry = _entries[i]; + // Stop at fractional panes - they handle redistribution naturally + final isPixelPane = + _pixelSizes[entry.id] != null || entry.initialSize is PaneSizePixel; + if (!isPixelPane) break; + + if (entry.effectiveResizeBehavior != ResizeBehavior.fixed) { + targets.add(entry); + } + } + } else { + // Collect panes from resizer down to 0 (in reverse order) + for (int i = resizerIndex; i >= 0; i--) { + final entry = _entries[i]; + // Stop at fractional panes - they handle redistribution naturally + final isPixelPane = + _pixelSizes[entry.id] != null || entry.initialSize is PaneSizePixel; + if (!isPixelPane) break; + + if (entry.effectiveResizeBehavior != ResizeBehavior.fixed) { + targets.add(entry); + } + } + } + + // Sort by behavior: eager first, then reluctant + targets.sort((a, b) { + final aBehavior = a.effectiveResizeBehavior; + final bBehavior = b.effectiveResizeBehavior; + if (aBehavior == ResizeBehavior.eager && + bBehavior == ResizeBehavior.reluctant) { + return -1; + } + if (aBehavior == ResizeBehavior.reluctant && + bBehavior == ResizeBehavior.eager) { + return 1; + } + return 0; + }); + + return targets; + } + + /// Cascades resize delta through multiple panes. + /// + /// When a pane hits its constraint, remaining delta flows to the next pane. + /// Panes are processed in priority order (eager before reluctant). + /// + /// [resizerIndex] is the index of the resizer being dragged. + /// [delta] is positive when increasing size in the forward direction. + /// [flipDirection] reverses the cascade direction (used when resizing pane + /// after the resizer instead of before). + double _cascadeResize({ + required int resizerIndex, + required double delta, + required ResizeContext context, + bool flipDirection = false, + }) { + if (delta == 0) return 0; + + // Determine direction based on delta sign + // Positive delta = panes before resizer grow, panes after shrink + // For cascade: we cascade to panes that need to absorb the opposite effect + // + // When flipDirection is true (resizing pane AFTER resizer): + // - Positive delta (growing) needs to take space from panes BEFORE resizer + // - So we flip: forward becomes !forward + var forward = delta > 0; + if (flipDirection) forward = !forward; + + // The "absorbing" panes are those that shrink to allow growth + final targets = _collectCascadeTargets( + resizerIndex: resizerIndex, + forward: forward, + ); + + if (targets.isEmpty) return delta; + + var remainingDelta = delta.abs(); + + // Only cascade to pixel panes - fractional panes handle redistribution + // automatically through the flex layout system + final pixelTargets = targets + .where((e) => + _pixelSizes[e.id] != null || e.initialSize is PaneSizePixel) + .toList(); + + // Try to absorb with pixel panes (one at a time) + for (final entry in pixelTargets) { + if (remainingDelta <= 0) break; + + final currentSize = _getPixelSizeForCalculation(entry.id) ?? + entry.initialSize.size; + + final (absorbed, remaining) = ResizeCalculator.calculateAbsorption( + currentSize: currentSize, + delta: -remainingDelta, + entry: entry, + context: context, + ); + + if (absorbed.abs() > 0) { + _pixelSizes[entry.id] = currentSize + absorbed; + } + + remainingDelta = remaining.abs(); + } + + // NOTE: We do NOT cascade to fractional panes. + // The flex layout automatically redistributes space when flexSpace changes. + // Explicitly modifying _fractionalSizes during cascade would lock the + // fractions to specific values and break subsequent resize operations. + // + // Fractional panes naturally absorb overflow/underflow through the flex + // system without needing explicit cascade handling. + + // Return consumed delta (original minus remaining, with original sign) + final consumed = delta.abs() - remainingDelta; + return delta > 0 ? consumed : -consumed; + } + // --------------------------------------------------------------------------- // Size Queries // --------------------------------------------------------------------------- diff --git a/lib/src/pane_entry.dart b/lib/src/pane_entry.dart index 4c58dd4..cdc28e9 100644 --- a/lib/src/pane_entry.dart +++ b/lib/src/pane_entry.dart @@ -1,6 +1,25 @@ import 'package:flutter/foundation.dart'; import 'package:panes/src/pane_size.dart'; +/// Controls how a pane participates in cascade resize operations. +/// +/// When a resize operation would push a pane past its constraints, +/// the remaining delta can cascade to neighboring panes based on their behavior. +enum ResizeBehavior { + /// Absorb resize delta first, before other panes. + /// + /// This is the default for fractional panes. + eager, + + /// Absorb resize delta last, after eager panes are exhausted. + reluctant, + + /// Never absorb cascade delta; skip entirely. + /// + /// This is the default for pixel-sized panes. + fixed, +} + /// Configuration for a single pane within a [PaneController]. @immutable class PaneEntry { @@ -28,6 +47,15 @@ class PaneEntry { /// (or 20.0 pixels if minSize is also not set). final PaneSize? autoHideThreshold; + /// How this pane participates in cascade resize operations. + /// + /// When null, defaults based on [initialSize] type: + /// - Pixel panes default to [ResizeBehavior.fixed] + /// - Fractional panes default to [ResizeBehavior.eager] + /// + /// Set explicitly to override the default behavior. + final ResizeBehavior? resizeBehavior; + /// Creates a [PaneEntry]. const PaneEntry({ required this.id, @@ -37,8 +65,21 @@ class PaneEntry { this.maxSize, this.autoHide = false, this.autoHideThreshold, + this.resizeBehavior, }); + /// Returns the effective resize behavior for this pane. + /// + /// If [resizeBehavior] is explicitly set, returns that value. + /// Otherwise, returns [ResizeBehavior.fixed] for pixel panes + /// and [ResizeBehavior.eager] for fractional panes. + ResizeBehavior get effectiveResizeBehavior { + if (resizeBehavior != null) return resizeBehavior!; + return initialSize is PaneSizePixel + ? ResizeBehavior.fixed + : ResizeBehavior.eager; + } + /// Creates a copy of this entry with the given fields replaced with new values. PaneEntry copyWith({ String? id, @@ -48,6 +89,7 @@ class PaneEntry { PaneSize? maxSize, bool? autoHide, PaneSize? autoHideThreshold, + ResizeBehavior? resizeBehavior, }) { return PaneEntry( id: id ?? this.id, @@ -57,6 +99,7 @@ class PaneEntry { maxSize: maxSize ?? this.maxSize, autoHide: autoHide ?? this.autoHide, autoHideThreshold: autoHideThreshold ?? this.autoHideThreshold, + resizeBehavior: resizeBehavior ?? this.resizeBehavior, ); } } diff --git a/lib/src/resize_calculator.dart b/lib/src/resize_calculator.dart index 3755529..161fc7c 100644 --- a/lib/src/resize_calculator.dart +++ b/lib/src/resize_calculator.dart @@ -204,6 +204,30 @@ class ResizeCalculator { return (currentFraction1 + deltaFlex, currentFraction2 - deltaFlex); } + /// Calculates how much delta a pane can absorb given its constraints. + /// + /// Returns (absorbed, remaining) where: + /// - absorbed: the portion of delta that was applied to this pane + /// - remaining: the leftover delta to cascade to the next pane + /// + /// A positive delta increases the pane size; negative decreases it. + static (double absorbed, double remaining) calculateAbsorption({ + required double currentSize, + required double delta, + required PaneEntry entry, + required ResizeContext context, + }) { + final minSize = getMinPixels(entry, context); + final maxSize = getMaxPixels(entry, context); + + final requestedSize = currentSize + delta; + final clampedSize = requestedSize.clamp(minSize, maxSize); + final absorbed = clampedSize - currentSize; + final remaining = delta - absorbed; + + return (absorbed, remaining); + } + /// Builds a [ResizeContext] from a list of entries and their current sizes. /// /// [getCurrentPixelSize] should return the current pixel size override, or null. @@ -219,15 +243,20 @@ class ResizeCalculator { double totalFlexSum = 0; for (final entry in entries) { - final pixelSize = getCurrentPixelSize(entry.id); - if (pixelSize != null) { - totalFixedSize += pixelSize; - } else if (entry.initialSize case PaneSizePixel(:final pixels)) { - totalFixedSize += pixels; - } else { - // Flex pane + // Check entry type FIRST - fractional panes stay fractional even if + // auto-hide state was initialized (which incorrectly stores fraction as pixels) + if (entry.initialSize is PaneSizeFraction) { + // Flex pane - always use fractional calculation final fraction = getCurrentFraction(entry.id); totalFlexSum += fraction ?? entry.initialSize.size; + } else { + // Pixel pane - check for size override + final pixelSize = getCurrentPixelSize(entry.id); + if (pixelSize != null) { + totalFixedSize += pixelSize; + } else { + totalFixedSize += (entry.initialSize as PaneSizePixel).pixels; + } } } diff --git a/test/cascade_resize_test.dart b/test/cascade_resize_test.dart new file mode 100644 index 0000000..6c8b62c --- /dev/null +++ b/test/cascade_resize_test.dart @@ -0,0 +1,438 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:panes/panes.dart'; + +void main() { + group('ResizeBehavior defaults', () { + test('pixel pane defaults to fixed', () { + final entry = PaneEntry( + id: 'pixel', + initialSize: PaneSize.pixel(100), + ); + expect(entry.effectiveResizeBehavior, ResizeBehavior.fixed); + }); + + test('fractional pane defaults to eager', () { + final entry = PaneEntry( + id: 'fraction', + initialSize: PaneSize.fraction(1.0), + ); + expect(entry.effectiveResizeBehavior, ResizeBehavior.eager); + }); + + test('explicit behavior overrides default for pixel pane', () { + final entry = PaneEntry( + id: 'pixel', + initialSize: PaneSize.pixel(100), + resizeBehavior: ResizeBehavior.eager, + ); + expect(entry.effectiveResizeBehavior, ResizeBehavior.eager); + }); + + test('explicit behavior overrides default for fractional pane', () { + final entry = PaneEntry( + id: 'fraction', + initialSize: PaneSize.fraction(1.0), + resizeBehavior: ResizeBehavior.fixed, + ); + expect(entry.effectiveResizeBehavior, ResizeBehavior.fixed); + }); + + test('copyWith preserves resizeBehavior', () { + final entry = PaneEntry( + id: 'test', + initialSize: PaneSize.pixel(100), + resizeBehavior: ResizeBehavior.reluctant, + ); + final copy = entry.copyWith(id: 'copy'); + expect(copy.resizeBehavior, ResizeBehavior.reluctant); + }); + }); + + group('Cascade resize', () { + const containerSize = 1000.0; + const resizerThickness = 4.0; + + test('pixel pane clamped to max on overshoot', () { + // Layout: [pixel:100, max:150] [fraction:1.0] [fraction:1.0] + // When pixel pane overshoots, it's clamped to max. + // Fractional panes don't get explicit sizes - flex layout handles it. + final controller = PaneController( + entries: [ + PaneEntry( + id: 'left', + initialSize: PaneSize.pixel(100), + maxSize: PaneSize.pixel(150), + resizeBehavior: ResizeBehavior.eager, + ), + PaneEntry( + id: 'middle', + initialSize: PaneSize.fraction(1.0), + ), + PaneEntry( + id: 'right', + initialSize: PaneSize.fraction(1.0), + ), + ], + ); + + // Initial sizes + expect(controller.getPixelSize('left'), isNull); // Uses initial + + // Resize left pane by 100 (would exceed max of 150 by 50) + controller.resize( + paneId: 'left', + delta: 100, + containerSize: containerSize, + resizerThickness: resizerThickness, + ); + + // Left pane should be clamped at max + expect(controller.getPixelSize('left'), 150); + + // Fractional panes should NOT have explicit sizes set + // The flex layout automatically handles redistribution + expect(controller.getFractionalSize('middle'), isNull); + expect(controller.getFractionalSize('right'), isNull); + }); + + test('fixed pixel panes are skipped in cascade', () { + // Layout: [eager pixel:100] [fixed pixel:200] [eager pixel:300] + // Only pixel panes participate in cascade, not fractional ones + final controller = PaneController( + entries: [ + PaneEntry( + id: 'left', + initialSize: PaneSize.pixel(100), + maxSize: PaneSize.pixel(150), + resizeBehavior: ResizeBehavior.eager, + ), + PaneEntry( + id: 'middle', + initialSize: PaneSize.pixel(200), + resizeBehavior: ResizeBehavior.fixed, + ), + PaneEntry( + id: 'right', + initialSize: PaneSize.pixel(300), + minSize: PaneSize.pixel(200), + resizeBehavior: ResizeBehavior.eager, + ), + ], + ); + + // Resize left pane past its max + controller.resize( + paneId: 'left', + delta: 100, + containerSize: containerSize, + resizerThickness: resizerThickness, + ); + + // Left pane should be at max + expect(controller.getPixelSize('left'), 150); + + // Middle (fixed) pane should be unchanged + expect(controller.getPixelSize('middle'), isNull); + + // Right (eager pixel) pane should have shrunk to absorb overflow + // Overflow is 50px (100 - 50 max), right should shrink from 300 to 250 + expect(controller.getPixelSize('right'), 250); + }); + + test('reluctant panes absorb after eager panes exhausted', () { + // Layout: [pixel:100] [eager:200, min:100] [reluctant:200, min:50] + final controller = PaneController( + entries: [ + PaneEntry( + id: 'left', + initialSize: PaneSize.pixel(100), + maxSize: PaneSize.pixel(200), + resizeBehavior: ResizeBehavior.eager, + ), + PaneEntry( + id: 'middle', + initialSize: PaneSize.pixel(200), + minSize: PaneSize.pixel(100), + resizeBehavior: ResizeBehavior.eager, + ), + PaneEntry( + id: 'right', + initialSize: PaneSize.pixel(200), + minSize: PaneSize.pixel(50), + resizeBehavior: ResizeBehavior.reluctant, + ), + ], + ); + + // Resize left pane by 300 (way past max of 200) + // Overflow of 200 should cascade + // Middle (eager) can shrink from 200 to 100 (absorbs 100) + // Right (reluctant) should then absorb remaining 100 + controller.resize( + paneId: 'left', + delta: 300, + containerSize: containerSize, + resizerThickness: resizerThickness, + ); + + // Left at max + expect(controller.getPixelSize('left'), 200); + + // Middle should be at min + expect(controller.getPixelSize('middle'), 100); + + // Right should have absorbed some delta + final rightSize = controller.getPixelSize('right'); + expect(rightSize, isNotNull); + expect(rightSize!, lessThan(200)); + }); + + test('cascade stops at constraints', () { + // All panes have constraints - cascade should stop when all exhausted + final controller = PaneController( + entries: [ + PaneEntry( + id: 'left', + initialSize: PaneSize.pixel(100), + maxSize: PaneSize.pixel(150), + resizeBehavior: ResizeBehavior.eager, + ), + PaneEntry( + id: 'right', + initialSize: PaneSize.pixel(100), + minSize: PaneSize.pixel(80), + resizeBehavior: ResizeBehavior.eager, + ), + ], + ); + + // Resize left past max - only 20 can cascade (right can go from 100 to 80) + controller.resize( + paneId: 'left', + delta: 100, + containerSize: containerSize, + resizerThickness: resizerThickness, + ); + + expect(controller.getPixelSize('left'), 150); + expect(controller.getPixelSize('right'), 80); + }); + + test('negative delta cascades correctly', () { + // Resizing to shrink should cascade in opposite direction + final controller = PaneController( + entries: [ + PaneEntry( + id: 'left', + initialSize: PaneSize.pixel(100), + maxSize: PaneSize.pixel(200), + resizeBehavior: ResizeBehavior.eager, + ), + PaneEntry( + id: 'right', + initialSize: PaneSize.pixel(100), + minSize: PaneSize.pixel(50), + resizeBehavior: ResizeBehavior.eager, + ), + ], + ); + + // Resize right pane to shrink by a lot + controller.resize( + paneId: 'right', + delta: -100, + containerSize: containerSize, + resizerThickness: resizerThickness, + resizerIndex: 0, // Resizer between left and right + ); + + // Right should be at min + expect(controller.getPixelSize('right'), 50); + }); + + test('fractional panes keep original fractions during cascade', () { + // Layout: [pixel:200, max:300] [fraction:1.0] [fraction:1.0] + // Fractional panes don't get explicit sizes during cascade. + // They naturally shrink via the flex layout when flexSpace decreases. + final controller = PaneController( + entries: [ + PaneEntry( + id: 'left', + initialSize: PaneSize.pixel(200), + maxSize: PaneSize.pixel(300), + resizeBehavior: ResizeBehavior.eager, + ), + PaneEntry( + id: 'middle', + initialSize: PaneSize.fraction(1.0), + minSize: PaneSize.pixel(100), + ), + PaneEntry( + id: 'right', + initialSize: PaneSize.fraction(1.0), + minSize: PaneSize.pixel(100), + ), + ], + ); + + // Both should be null initially + expect(controller.getFractionalSize('middle'), isNull); + expect(controller.getFractionalSize('right'), isNull); + + // Resize left pane way past its max + controller.resize( + paneId: 'left', + delta: 200, + containerSize: containerSize, + resizerThickness: resizerThickness, + ); + + // Left should be at max + expect(controller.getPixelSize('left'), 300); + + // Fractional panes should STILL be null (no explicit override) + // The flex layout handles the redistribution automatically + expect(controller.getFractionalSize('middle'), isNull); + expect(controller.getFractionalSize('right'), isNull); + }); + + test('fractional panes can be resized after cascade', () { + // This tests that fractional panes remain resizable after cascade + final controller = PaneController( + entries: [ + PaneEntry( + id: 'trigger', + initialSize: PaneSize.pixel(100), + maxSize: PaneSize.pixel(150), + resizeBehavior: ResizeBehavior.eager, + ), + PaneEntry( + id: 'flex1', + initialSize: PaneSize.fraction(1.0), + minSize: PaneSize.pixel(50), + ), + PaneEntry( + id: 'flex2', + initialSize: PaneSize.fraction(1.0), + minSize: PaneSize.pixel(50), + ), + ], + ); + + // First, trigger cascade + controller.resize( + paneId: 'trigger', + delta: 100, + containerSize: 500, + resizerThickness: 4, + ); + + // Fractions should still be null (not locked) + expect(controller.getFractionalSize('flex1'), isNull); + expect(controller.getFractionalSize('flex2'), isNull); + + // Now resize between the fractional panes + controller.resize( + paneId: 'flex1', + delta: 50, + containerSize: 500, + resizerThickness: 4, + adjacentPaneId: 'flex2', + ); + + // Now they should have explicit fractional sizes + expect(controller.getFractionalSize('flex1'), isNotNull); + expect(controller.getFractionalSize('flex2'), isNotNull); + + // flex1 should have grown, flex2 should have shrunk + expect(controller.getFractionalSize('flex1')!, greaterThan(1.0)); + expect(controller.getFractionalSize('flex2')!, lessThan(1.0)); + }); + }); + + group('Backward compatibility', () { + test('existing resize behavior unchanged for single pixel pane', () { + final controller = PaneController( + entries: [ + PaneEntry( + id: 'left', + initialSize: PaneSize.pixel(100), + minSize: PaneSize.pixel(50), + maxSize: PaneSize.pixel(200), + ), + PaneEntry( + id: 'right', + initialSize: PaneSize.fraction(1.0), + ), + ], + ); + + controller.resize( + paneId: 'left', + delta: 50, + containerSize: 500, + resizerThickness: 4, + ); + + expect(controller.getPixelSize('left'), 150); + // Right pane (fractional) should not have pixel size set + expect(controller.getPixelSize('right'), isNull); + }); + + test('existing resize behavior unchanged for fractional panes', () { + final controller = PaneController( + entries: [ + PaneEntry( + id: 'left', + initialSize: PaneSize.fraction(1.0), + ), + PaneEntry( + id: 'right', + initialSize: PaneSize.fraction(1.0), + ), + ], + ); + + controller.resize( + paneId: 'left', + delta: 50, + containerSize: 500, + resizerThickness: 4, + adjacentPaneId: 'right', + ); + + // Both should have fractional sizes updated + expect(controller.getFractionalSize('left'), isNotNull); + expect(controller.getFractionalSize('right'), isNotNull); + }); + + test('pixel panes default to fixed (no cascade)', () { + // Default pixel panes should NOT cascade + final controller = PaneController( + entries: [ + PaneEntry( + id: 'left', + initialSize: PaneSize.pixel(100), + maxSize: PaneSize.pixel(150), + ), + PaneEntry( + id: 'right', + initialSize: PaneSize.pixel(100), + ), + ], + ); + + // Even resizing past max shouldn't affect right (fixed by default) + controller.resize( + paneId: 'left', + delta: 100, + containerSize: 500, + resizerThickness: 4, + ); + + expect(controller.getPixelSize('left'), 150); + // Right pane should be unchanged (fixed by default) + expect(controller.getPixelSize('right'), isNull); + }); + }); +} From 6018831eaba0d61dc37f2d14252df7908d40dfac Mon Sep 17 00:00:00 2001 From: Simon Pham Date: Tue, 7 Apr 2026 21:39:54 +0700 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20Fix=20auto-hide=20bypass=20a?= =?UTF-8?q?nd=20stale=20tracking=20in=20cascade=20resize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/src/pane_controller.dart | 37 +++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/lib/src/pane_controller.dart b/lib/src/pane_controller.dart index 9069919..8e7aba1 100644 --- a/lib/src/pane_controller.dart +++ b/lib/src/pane_controller.dart @@ -64,8 +64,9 @@ class PaneController extends ChangeNotifier { _visibilityOverrides[id] = true; // Restore size from auto-hide state if available - if (_autoHideStates[id] case AutoHideHidden(:final restoreSize?) || - AutoHidePendingReveal(:final restoreSize)) { + if (_autoHideStates[id] + case AutoHideHidden(:final restoreSize?) || + AutoHidePendingReveal(:final restoreSize)) { _pixelSizes[id] = restoreSize; _autoHideStates[id] = AutoHideVisible(pixelSize: restoreSize); } @@ -194,8 +195,7 @@ class PaneController extends ChangeNotifier { // When resizing a fractional pane adjacent to a pixel pane, // we need to respect the fractional pane's min/max constraints. // The fractional pane grows/shrinks opposite to the pixel pane. - final fractionalCurrentSize = - _getPixelSizeForCalculation(paneId) ?? + final fractionalCurrentSize = _getPixelSizeForCalculation(paneId) ?? ResizeCalculator.toPixels(entry.initialSize, context); final fractionalNewSize = fractionalCurrentSize + delta; @@ -498,16 +498,16 @@ class PaneController extends ChangeNotifier { // Only cascade to pixel panes - fractional panes handle redistribution // automatically through the flex layout system final pixelTargets = targets - .where((e) => - _pixelSizes[e.id] != null || e.initialSize is PaneSizePixel) + .where( + (e) => _pixelSizes[e.id] != null || e.initialSize is PaneSizePixel) .toList(); // Try to absorb with pixel panes (one at a time) for (final entry in pixelTargets) { if (remainingDelta <= 0) break; - final currentSize = _getPixelSizeForCalculation(entry.id) ?? - entry.initialSize.size; + final currentSize = + _getPixelSizeForCalculation(entry.id) ?? entry.initialSize.size; final (absorbed, remaining) = ResizeCalculator.calculateAbsorption( currentSize: currentSize, @@ -517,20 +517,23 @@ class PaneController extends ChangeNotifier { ); if (absorbed.abs() > 0) { - _pixelSizes[entry.id] = currentSize + absorbed; + // Clear any previous tracking state because the pane is being pushed + // by its neighbor, meaning its virtual dimensions are no longer valid. + _maxOvershootPositions.remove(entry.id); + _minUndershootPositions.remove(entry.id); + + // Properly resize the pane, which correctly triggers auto-hide if needed + _resizePixelPane( + entry.id, + entry, + absorbed, + context, + ); } remainingDelta = remaining.abs(); } - // NOTE: We do NOT cascade to fractional panes. - // The flex layout automatically redistributes space when flexSpace changes. - // Explicitly modifying _fractionalSizes during cascade would lock the - // fractions to specific values and break subsequent resize operations. - // - // Fractional panes naturally absorb overflow/underflow through the flex - // system without needing explicit cascade handling. - // Return consumed delta (original minus remaining, with original sign) final consumed = delta.abs() - remainingDelta; return delta > 0 ? consumed : -consumed; From 16c078387e06b359b4c2a9b5cd8e2d27d7369a62 Mon Sep 17 00:00:00 2001 From: Simon Pham Date: Tue, 7 Apr 2026 21:39:55 +0700 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=8E=A8=20Reformat=20example=20code=20?= =?UTF-8?q?entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/lib/cascade_demo.dart | 69 ++++++++++--------- example/lib/main.dart | 121 +++++++++++++++++----------------- example/pubspec.lock | 18 ++--- 3 files changed, 109 insertions(+), 99 deletions(-) diff --git a/example/lib/cascade_demo.dart b/example/lib/cascade_demo.dart index 3e937d5..e440904 100644 --- a/example/lib/cascade_demo.dart +++ b/example/lib/cascade_demo.dart @@ -169,10 +169,10 @@ class _CascadeDemoState extends State { Widget _buildVerticalPane(String id) { return switch (id) { 'main' => MultiPane( - controller: _horizontalController, - direction: Axis.horizontal, - paneBuilder: (context, id) => _buildHorizontalPane(id), - ), + controller: _horizontalController, + direction: Axis.horizontal, + paneBuilder: (context, id) => _buildHorizontalPane(id), + ), 'terminal' => _buildTerminalPanel(), _ => const SizedBox(), }; @@ -285,16 +285,15 @@ class _CascadeDemoState extends State { return ListenableBuilder( listenable: _verticalController, builder: (context, _) { - final entry = _verticalController.entries - .firstWhere((e) => e.id == 'terminal'); + final entry = _verticalController.entries.firstWhere( + (e) => e.id == 'terminal', + ); return Container( height: 24, color: const Color(0xFF252526), padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( - children: [ - Expanded(child: _sizeChip(entry, _verticalController)), - ], + children: [Expanded(child: _sizeChip(entry, _verticalController))], ), ); }, @@ -302,7 +301,8 @@ class _CascadeDemoState extends State { } Widget _sizeChip(PaneEntry entry, PaneController controller) { - final size = controller.getPixelSize(entry.id) ?? + final size = + controller.getPixelSize(entry.id) ?? controller.getFractionalSize(entry.id); final behavior = entry.effectiveResizeBehavior; final color = behavior == ResizeBehavior.fixed ? Colors.red : Colors.green; @@ -310,10 +310,10 @@ class _CascadeDemoState extends State { final sizeText = controller.getPixelSize(entry.id) != null ? '${size!.toInt()}px' : controller.getFractionalSize(entry.id) != null - ? 'flex: ${size!.toStringAsFixed(2)}' - : entry.initialSize is PaneSizePixel - ? '${entry.initialSize.size.toInt()}px' - : 'flex'; + ? 'flex: ${size!.toStringAsFixed(2)}' + : entry.initialSize is PaneSizePixel + ? '${entry.initialSize.size.toInt()}px' + : 'flex'; return Container( margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 4), @@ -375,8 +375,9 @@ class _CascadeDemoState extends State { } Widget _buildExplorerPanel() { - final entry = - _horizontalController.entries.firstWhere((e) => e.id == 'explorer'); + final entry = _horizontalController.entries.firstWhere( + (e) => e.id == 'explorer', + ); return _panelContainer( color: Colors.green, header: 'EXPLORER', @@ -436,12 +437,12 @@ class _CascadeDemoState extends State { i == 1 ? 'import \'package:flutter/material.dart\';' : i == 3 - ? 'void main() {' - : i == 4 - ? ' runApp(const MyApp());' - : i == 5 - ? '}' - : '', + ? 'void main() {' + : i == 4 + ? ' runApp(const MyApp());' + : i == 5 + ? '}' + : '', style: const TextStyle( color: Colors.white70, fontSize: 12, @@ -459,8 +460,9 @@ class _CascadeDemoState extends State { } Widget _buildOutlinePanel() { - final entry = - _horizontalController.entries.firstWhere((e) => e.id == 'outline'); + final entry = _horizontalController.entries.firstWhere( + (e) => e.id == 'outline', + ); return _panelContainer( color: Colors.red, header: 'OUTLINE (FIXED)', @@ -481,10 +483,12 @@ class _CascadeDemoState extends State { } Widget _buildTerminalPanel() { - final entry = - _verticalController.entries.firstWhere((e) => e.id == 'terminal'); - final editorCount = - _horizontalEntries.where((e) => e.id.startsWith('editor')).length; + final entry = _verticalController.entries.firstWhere( + (e) => e.id == 'terminal', + ); + final editorCount = _horizontalEntries + .where((e) => e.id.startsWith('editor')) + .length; return _panelContainer( color: Colors.green, @@ -602,7 +606,9 @@ class _CascadeDemoState extends State { padding: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( color: color.withValues(alpha: 0.1), - borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(3), + ), ), child: Row( children: [ @@ -617,7 +623,10 @@ class _CascadeDemoState extends State { ), const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), decoration: BoxDecoration( color: color.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(2), diff --git a/example/lib/main.dart b/example/lib/main.dart index 3ccd252..7ffc5fb 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -251,7 +251,8 @@ class _IdeExampleState extends State { // which pane ID is set in each controller if (isMaximized) { final centerMaxId = _ideController - .centerController.maximizedPaneId; + .centerController + .maximizedPaneId; if (centerMaxId == IdePane.bottom.id) { _isTerminalMaximized = true; _isEditorMaximized = false; @@ -363,9 +364,9 @@ class _IdeExampleState extends State { _titleBarAction( Icons.swap_horiz, 'Cascade Resize Demo', - () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const CascadeDemo()), - ), + () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const CascadeDemo())), ), const SizedBox(width: 8), // Toggle buttons for panels (right side) @@ -1067,68 +1068,68 @@ class _IdeExampleState extends State { } Widget _keyword(String text) => Text( - text, - style: const TextStyle( - color: IdeColors.keyword, - fontSize: 13, - fontFamily: 'JetBrains Mono', - height: 1.5, - ), - ); + text, + style: const TextStyle( + color: IdeColors.keyword, + fontSize: 13, + fontFamily: 'JetBrains Mono', + height: 1.5, + ), + ); Widget _string(String text) => Text( - text, - style: const TextStyle( - color: IdeColors.string, - fontSize: 13, - fontFamily: 'JetBrains Mono', - height: 1.5, - ), - ); + text, + style: const TextStyle( + color: IdeColors.string, + fontSize: 13, + fontFamily: 'JetBrains Mono', + height: 1.5, + ), + ); Widget _comment(String text) => Text( - text, - style: const TextStyle( - color: IdeColors.comment, - fontSize: 13, - fontFamily: 'JetBrains Mono', - height: 1.5, - ), - ); + text, + style: const TextStyle( + color: IdeColors.comment, + fontSize: 13, + fontFamily: 'JetBrains Mono', + height: 1.5, + ), + ); Widget _function(String text) => Text( - text, - style: const TextStyle( - color: IdeColors.function, - fontSize: 13, - fontFamily: 'JetBrains Mono', - height: 1.5, - ), - ); + text, + style: const TextStyle( + color: IdeColors.function, + fontSize: 13, + fontFamily: 'JetBrains Mono', + height: 1.5, + ), + ); Widget _type(String text) => Text( - text, - style: const TextStyle( - color: IdeColors.type, - fontSize: 13, - fontFamily: 'JetBrains Mono', - height: 1.5, - ), - ); + text, + style: const TextStyle( + color: IdeColors.type, + fontSize: 13, + fontFamily: 'JetBrains Mono', + height: 1.5, + ), + ); Widget _variable(String text) => Text( - text, - style: const TextStyle( - color: IdeColors.variable, - fontSize: 13, - fontFamily: 'JetBrains Mono', - height: 1.5, - ), - ); + text, + style: const TextStyle( + color: IdeColors.variable, + fontSize: 13, + fontFamily: 'JetBrains Mono', + height: 1.5, + ), + ); Widget _plain(String text) => Text( - text, - style: const TextStyle( - color: IdeColors.text, - fontSize: 13, - fontFamily: 'JetBrains Mono', - height: 1.5, - ), - ); + text, + style: const TextStyle( + color: IdeColors.text, + fontSize: 13, + fontFamily: 'JetBrains Mono', + height: 1.5, + ), + ); Widget _buildRightPanel() { // Fleet style: outline panel with rounded corners diff --git a/example/pubspec.lock b/example/pubspec.lock index 1f6c9b8..fa523b0 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -111,18 +111,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -137,7 +137,7 @@ packages: path: ".." relative: true source: path - version: "1.0.0+1" + version: "1.1.1" path: dependency: transitive description: @@ -195,10 +195,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" vector_math: dependency: transitive description: