From 8475e2e33a6a2c9a2862dc6e693ca902146cdd64 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 23 Apr 2026 09:13:32 +0200 Subject: [PATCH] add window resize handles for frameless mode Without OS decorations there are no resize handles. Added 6px edge and 12px corner invisible hit zones around the window perimeter via ClideResizeBorder. Each zone changes the cursor and calls gtk_window_begin_resize_drag via the clide/window method channel with a GdkWindowEdge direction. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/app.dart | 19 +++++---- lib/kernel/src/window_controls.dart | 10 +++++ lib/widgets/src/clide_resize_border.dart | 53 ++++++++++++++++++++++++ lib/widgets/widgets.dart | 1 + linux/runner/my_application.cc | 20 +++++++++ 5 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 lib/widgets/src/clide_resize_border.dart diff --git a/lib/app.dart b/lib/app.dart index 5e6204a4..ee7b6643 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -84,14 +84,17 @@ class _RootShellState extends State<_RootShell> { onKeyEvent: _onKey, child: ColoredBox( color: tokens.globalBackground, - child: DialogHost( - router: widget.services.dialog, - child: Stack( - children: [ - const Positioned.fill(child: RootLayout()), - const ClidePalette(), - const Positioned.fill(child: _WelcomeOverlay()), - ], + child: ClideResizeBorder( + windowControls: widget.services.window, + child: DialogHost( + router: widget.services.dialog, + child: Stack( + children: [ + const Positioned.fill(child: RootLayout()), + const ClidePalette(), + const Positioned.fill(child: _WelcomeOverlay()), + ], + ), ), ), ), diff --git a/lib/kernel/src/window_controls.dart b/lib/kernel/src/window_controls.dart index 29c4eacf..c29981a6 100644 --- a/lib/kernel/src/window_controls.dart +++ b/lib/kernel/src/window_controls.dart @@ -3,6 +3,8 @@ import 'package:flutter/services.dart'; enum ChromeStyle { seam, prompt, inline } +enum ResizeEdge { topLeft, top, topRight, left, right, bottomLeft, bottom, bottomRight } + class WindowControls extends ChangeNotifier { static const _channel = MethodChannel('clide/window'); @@ -15,6 +17,14 @@ class WindowControls extends ChangeNotifier { notifyListeners(); } + Future startResize(ResizeEdge edge) async { + try { + await _channel.invokeMethod('startResize', edge.index); + } on MissingPluginException { + // no-op + } + } + Future startDrag() async { try { await _channel.invokeMethod('startDrag'); diff --git a/lib/widgets/src/clide_resize_border.dart b/lib/widgets/src/clide_resize_border.dart new file mode 100644 index 00000000..3298a83c --- /dev/null +++ b/lib/widgets/src/clide_resize_border.dart @@ -0,0 +1,53 @@ +import 'package:clide/kernel/src/window_controls.dart'; +import 'package:flutter/widgets.dart'; + +class ClideResizeBorder extends StatelessWidget { + const ClideResizeBorder({super.key, required this.windowControls, required this.child}); + + final WindowControls windowControls; + final Widget child; + + static const double _edge = 6; + static const double _corner = 12; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill(child: child), + // Corners + _zone(Alignment.topLeft, _corner, _corner, SystemMouseCursors.resizeUpLeft, ResizeEdge.topLeft), + _zone(Alignment.topRight, _corner, _corner, SystemMouseCursors.resizeUpRight, ResizeEdge.topRight), + _zone(Alignment.bottomLeft, _corner, _corner, SystemMouseCursors.resizeDownLeft, ResizeEdge.bottomLeft), + _zone(Alignment.bottomRight, _corner, _corner, SystemMouseCursors.resizeDownRight, ResizeEdge.bottomRight), + // Edges + Positioned(top: _corner, bottom: _corner, left: 0, width: _edge, child: _edgeZone(SystemMouseCursors.resizeLeft, ResizeEdge.left)), + Positioned(top: _corner, bottom: _corner, right: 0, width: _edge, child: _edgeZone(SystemMouseCursors.resizeRight, ResizeEdge.right)), + Positioned(left: _corner, right: _corner, top: 0, height: _edge, child: _edgeZone(SystemMouseCursors.resizeUp, ResizeEdge.top)), + Positioned(left: _corner, right: _corner, bottom: 0, height: _edge, child: _edgeZone(SystemMouseCursors.resizeDown, ResizeEdge.bottom)), + ], + ); + } + + Widget _zone(Alignment alignment, double w, double h, MouseCursor cursor, ResizeEdge edge) { + return Positioned( + left: alignment.x < 0 ? 0 : null, + right: alignment.x > 0 ? 0 : null, + top: alignment.y < 0 ? 0 : null, + bottom: alignment.y > 0 ? 0 : null, + width: w, + height: h, + child: _edgeZone(cursor, edge), + ); + } + + Widget _edgeZone(MouseCursor cursor, ResizeEdge edge) { + return MouseRegion( + cursor: cursor, + child: GestureDetector( + onPanStart: (_) => windowControls.startResize(edge), + child: const ColoredBox(color: Color(0x00000000)), + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 2af58d4c..bc888657 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -13,6 +13,7 @@ export 'src/clide_icon.dart'; export 'src/clide_icon_rail.dart'; export 'src/clide_palette.dart'; export 'src/clide_pane_chrome.dart'; +export 'src/clide_resize_border.dart'; export 'src/clide_pty_view.dart'; export 'src/clide_scrollbar.dart'; export 'src/clide_spine.dart'; diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 9c3c95a9..e9bdabb1 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -113,6 +113,26 @@ static void my_application_activate(GApplication* application) { gtk_window_close(w); response = FL_METHOD_RESPONSE( fl_method_success_response_new(fl_value_new_null())); + } else if (g_strcmp0(method, "startResize") == 0) { + // edge: 0=topLeft 1=top 2=topRight 3=left 4=right + // 5=bottomLeft 6=bottom 7=bottomRight + FlValue* args = fl_method_call_get_args(method_call); + int edge = 7; // default: bottom-right + if (fl_value_get_type(args) == FL_VALUE_TYPE_INT) { + edge = (int)fl_value_get_int(args); + } + static const GdkWindowEdge edges[] = { + GDK_WINDOW_EDGE_NORTH_WEST, GDK_WINDOW_EDGE_NORTH, + GDK_WINDOW_EDGE_NORTH_EAST, GDK_WINDOW_EDGE_WEST, + GDK_WINDOW_EDGE_EAST, GDK_WINDOW_EDGE_SOUTH_WEST, + GDK_WINDOW_EDGE_SOUTH, GDK_WINDOW_EDGE_SOUTH_EAST, + }; + if (edge >= 0 && edge < 8) { + gtk_window_begin_resize_drag(w, edges[edge], 1, 0, 0, + GDK_CURRENT_TIME); + } + response = FL_METHOD_RESPONSE( + fl_method_success_response_new(fl_value_new_null())); } else if (g_strcmp0(method, "isMaximized") == 0) { response = FL_METHOD_RESPONSE(fl_method_success_response_new( fl_value_new_bool(gtk_window_is_maximized(w))));