From 538472f7ec2caf85728f34917fb9ffd2213ce023 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 23 Apr 2026 08:16:28 +0200 Subject: [PATCH] replace OS title bar with per-column 24px hats (D-057) Hide GTK title bar via gtk_window_set_decorated(FALSE). Add MethodChannel('clide/window') for drag/minimize/maximize/close wired to GTK window functions. Dart WindowControls service wraps the channel. Three per-column hats in RootLayout: left (macOS traffic lights or plain drag), center (project > branch label), right (minimize/maximize/close glyph buttons on Linux). Resolves Q-006. Co-Authored-By: Claude Opus 4.6 (1M context) --- decisions/architecture.md | 9 ++ decisions/questions-architecture.md | 4 +- lib/app.dart | 19 ++- lib/kernel/kernel.dart | 1 + lib/kernel/src/facade.dart | 5 + lib/kernel/src/window_controls.dart | 58 ++++++++ lib/widgets/src/clide_column_hat.dart | 182 ++++++++++++++++++++++++++ lib/widgets/widgets.dart | 1 + linux/runner/my_application.cc | 77 +++++++---- 9 files changed, 325 insertions(+), 31 deletions(-) create mode 100644 lib/kernel/src/window_controls.dart create mode 100644 lib/widgets/src/clide_column_hat.dart diff --git a/decisions/architecture.md b/decisions/architecture.md index e8b172b6..a0de0a1a 100644 --- a/decisions/architecture.md +++ b/decisions/architecture.md @@ -226,4 +226,13 @@ Core, rendering, IPC, kernel, panel manager. - **Cross-reference:** [D-005](#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended), [D-041](#d-041-claude-panes-one-primary-per-repo-tmux-backed) (tmux persistence), [D-001](#d-001-cli-first-not-mcp) (CLI-first surface preserved via C client). - **Raised by:** 2026-04-23 architectural simplification. +### D-057: Frameless custom chrome with per-column 24px hats +- **Date:** 2026-04-23 +- **Decision:** The OS-native title bar is hidden. Each of the three columns wears its own 24px "hat" that serves as both a drag region and a host for window controls. Left hat: macOS traffic lights (Linux/Windows: plain drag). Center hat: `clide > branch` label, always present. Right hat: minimize/maximize/close glyph buttons on Linux/Windows (macOS: plain drag). Entire hat surface is draggable; buttons opt out of hit testing. When a column collapses to a 12px spine, its hat shrinks to a 12px drag cap — no buttons, still draggable. The center hat never collapses. Three `ChromeStyle` variants: `seam` (default desktop — full hats), `prompt` (center hat only — presentations/focus), `inline` (web/wasm — no hats, browser owns window controls). Persisted in settings as `app.chromeStyle`. Platform bridge via `MethodChannel('clide/window')` — custom GTK C and Cocoa Swift handlers, no third-party package. +- **Resolves:** [Q-006](questions-architecture.md#q-006-window-chrome-native-frame-vs-frameless-custom). +- **Rationale:** The GTK headerbar wastes 30+ vertical pixels and clashes with the custom theme. Per-column hats add zero net rows — they reuse the space each column header already occupied. Custom FFI avoids a `window_manager` dependency (D-031). The `ChromeStyle` enum keeps web builds clean and allows user override. +- **Cost:** ~150 lines C (GTK) + ~100 lines Swift (Cocoa) for the platform channel. Window controls become unreachable when their column collapses — mitigated by keyboard shortcuts (`⌘Q` to close, `⌘1`/`⌘3` to expand). +- **Cross-reference:** [D-047](#d-047-interaction-model-claude-is-home-layout) (center hat always visible), [D-051](#d-051-panel-collapse-12px-spine-with-badge) (spine-cap behavior). +- **Raised by:** 2026-04-23 interaction model refinement. + --- diff --git a/decisions/questions-architecture.md b/decisions/questions-architecture.md index 82943227..3cd1fdca 100644 --- a/decisions/questions-architecture.md +++ b/decisions/questions-architecture.md @@ -36,9 +36,9 @@ ticket persistence. - **Source:** CLAUDE.md "Open questions" footer. ### Q-006: Window chrome — native frame vs frameless custom -- **Status:** Open +- **Status:** Resolved → [D-057](architecture.md#d-057-frameless-custom-chrome-with-per-column-24px-hats) - **Question:** Does clide ship with the OS-native window frame (title bar, min/max/close from the WM) or a frameless custom chrome that gives us pixel control at the cost of reimplementing window controls per-platform? -- **Context:** Surfaced during Tier-0 plumbing discussion; decision deferred. +- **Context:** Surfaced during Tier-0 plumbing discussion; resolved 2026-04-23 — frameless with per-column 24px hats. - **Source:** 2026-04-21 planning. ### Q-007: macOS app bundle signing / notarisation diff --git a/lib/app.dart b/lib/app.dart index fe6eb127..2f908578 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -141,7 +141,10 @@ class RootLayout extends StatelessWidget { else if (sidebarVisible) ...[ SizedBox( width: sidebarSize, - child: SlotHost(slot: Slots.sidebar), + child: Column(children: [ + ColumnHat.left(windowControls: kernel.window), + Expanded(child: SlotHost(slot: Slots.sidebar)), + ]), ), DragResizeHandle( arrangement: a, @@ -149,7 +152,14 @@ class RootLayout extends StatelessWidget { axis: Axis.horizontal, ), ], - const Expanded(child: SlotHost(slot: Slots.workspace)), + Expanded(child: Column(children: [ + ColumnHat.center( + windowControls: kernel.window, + project: kernel.project.current?.path.split('/').last, + branch: null, + ), + const Expanded(child: SlotHost(slot: Slots.workspace)), + ])), if (contextVisible && contextCollapsed) ClideSpine( label: 'context', @@ -164,7 +174,10 @@ class RootLayout extends StatelessWidget { ), SizedBox( width: contextSize, - child: SlotHost(slot: Slots.contextPanel), + child: Column(children: [ + ColumnHat.right(windowControls: kernel.window), + Expanded(child: SlotHost(slot: Slots.contextPanel)), + ]), ), ], ], diff --git a/lib/kernel/kernel.dart b/lib/kernel/kernel.dart index 2f1c7a33..f210aecb 100644 --- a/lib/kernel/kernel.dart +++ b/lib/kernel/kernel.dart @@ -46,3 +46,4 @@ export 'src/theme/palette.dart'; export 'src/theme/resolver.dart'; export 'src/theme/semantic.dart'; export 'src/theme/tokens.dart'; +export 'src/window_controls.dart'; diff --git a/lib/kernel/src/facade.dart b/lib/kernel/src/facade.dart index 4125ed2b..4866e1b5 100644 --- a/lib/kernel/src/facade.dart +++ b/lib/kernel/src/facade.dart @@ -26,6 +26,7 @@ import 'package:clide/kernel/src/settings.dart'; import 'package:clide/kernel/src/theme/controller.dart'; import 'package:clide/kernel/src/theme/loader.dart'; import 'package:clide/kernel/src/tray.dart'; +import 'package:clide/kernel/src/window_controls.dart'; import 'package:flutter/widgets.dart'; /// Aggregated kernel services. Feature code that runs outside a @@ -55,6 +56,7 @@ class KernelServices { required this.focus, required this.project, required this.extensions, + required this.window, }); final Logger log; @@ -79,6 +81,7 @@ class KernelServices { final FocusTracker focus; final ProjectManager project; final ExtensionManager extensions; + final WindowControls window; static Future boot({ required Directory appDir, @@ -124,6 +127,7 @@ class KernelServices { final os = OsBridge(log: log, events: events); final net = NetworkStatus(); final focus = FocusTracker(); + final window = WindowControls(); final project = ProjectManager( log: log, events: events, @@ -187,6 +191,7 @@ class KernelServices { focus: focus, project: project, extensions: extensions, + window: window, ); } diff --git a/lib/kernel/src/window_controls.dart b/lib/kernel/src/window_controls.dart new file mode 100644 index 00000000..29c4eacf --- /dev/null +++ b/lib/kernel/src/window_controls.dart @@ -0,0 +1,58 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +enum ChromeStyle { seam, prompt, inline } + +class WindowControls extends ChangeNotifier { + static const _channel = MethodChannel('clide/window'); + + ChromeStyle _style = ChromeStyle.seam; + ChromeStyle get style => _style; + + void setStyle(ChromeStyle s) { + if (_style == s) return; + _style = s; + notifyListeners(); + } + + Future startDrag() async { + try { + await _channel.invokeMethod('startDrag'); + } on MissingPluginException { + // Web or unsupported platform — no-op. + } + } + + Future minimize() async { + try { + await _channel.invokeMethod('minimize'); + } on MissingPluginException { + // no-op + } + } + + Future toggleMaximize() async { + try { + await _channel.invokeMethod('maximize'); + } on MissingPluginException { + // no-op + } + } + + Future close() async { + try { + await _channel.invokeMethod('close'); + } on MissingPluginException { + // no-op + } + } + + Future isMaximized() async { + try { + final result = await _channel.invokeMethod('isMaximized'); + return result ?? false; + } on MissingPluginException { + return false; + } + } +} diff --git a/lib/widgets/src/clide_column_hat.dart b/lib/widgets/src/clide_column_hat.dart new file mode 100644 index 00000000..cf4cbc17 --- /dev/null +++ b/lib/widgets/src/clide_column_hat.dart @@ -0,0 +1,182 @@ +import 'dart:io' show Platform; + +import 'package:clide/kernel/src/theme/controller.dart'; +import 'package:clide/kernel/src/theme/tokens.dart'; +import 'package:clide/kernel/src/window_controls.dart'; +import 'package:clide/widgets/src/clide_icon.dart'; +import 'package:clide/widgets/src/clide_text.dart'; +import 'package:clide/widgets/src/icons/phosphor.dart'; +import 'package:clide/widgets/src/typography.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/widgets.dart'; + +const double hatHeight = 24; + +class ColumnHat extends StatelessWidget { + const ColumnHat._({required this.position, required this.windowControls, this.projectLabel, this.branchLabel}); + + final HatPosition position; + final WindowControls windowControls; + final String? projectLabel; + final String? branchLabel; + + factory ColumnHat.left({required WindowControls windowControls}) => + ColumnHat._(position: HatPosition.left, windowControls: windowControls); + + factory ColumnHat.center({required WindowControls windowControls, String? project, String? branch}) => + ColumnHat._(position: HatPosition.center, windowControls: windowControls, projectLabel: project, branchLabel: branch); + + factory ColumnHat.right({required WindowControls windowControls}) => + ColumnHat._(position: HatPosition.right, windowControls: windowControls); + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return GestureDetector( + onPanStart: (_) => windowControls.startDrag(), + child: Container( + height: hatHeight, + color: tokens.panelHeader, + child: switch (position) { + HatPosition.left => _LeftContent(tokens: tokens, wc: windowControls), + HatPosition.center => _CenterContent(tokens: tokens, project: projectLabel, branch: branchLabel), + HatPosition.right => _RightContent(tokens: tokens, wc: windowControls), + }, + ), + ); + } +} + +enum HatPosition { left, center, right } + +class _LeftContent extends StatelessWidget { + const _LeftContent({required this.tokens, required this.wc}); + final SurfaceTokens tokens; + final WindowControls wc; + + @override + Widget build(BuildContext context) { + if (kIsWeb) return const SizedBox.expand(); + final isMac = !kIsWeb && Platform.isMacOS; + if (!isMac) return const SizedBox.expand(); + return Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + children: [ + _TrafficDot(color: const Color(0xFFFF5F57), onTap: wc.close), + const SizedBox(width: 6), + _TrafficDot(color: const Color(0xFFFEBC2E), onTap: wc.minimize), + const SizedBox(width: 6), + _TrafficDot(color: const Color(0xFF28C840), onTap: wc.toggleMaximize), + ], + ), + ); + } +} + +class _CenterContent extends StatelessWidget { + const _CenterContent({required this.tokens, this.project, this.branch}); + final SurfaceTokens tokens; + final String? project; + final String? branch; + + @override + Widget build(BuildContext context) { + final parts = []; + if (project != null) parts.add(project!); + if (branch != null) parts.add(branch!); + final label = parts.isEmpty ? 'clide' : parts.join(' > '); + return Center( + child: ClideText(label, fontSize: 12, color: tokens.globalTextMuted, fontFamily: clideMonoFamily), + ); + } +} + +class _RightContent extends StatelessWidget { + const _RightContent({required this.tokens, required this.wc}); + final SurfaceTokens tokens; + final WindowControls wc; + + @override + Widget build(BuildContext context) { + if (kIsWeb) return const SizedBox.expand(); + final isMac = !kIsWeb && Platform.isMacOS; + if (isMac) return const SizedBox.expand(); + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _WinButton(icon: const PhosphorIconPainter(0xe32a), onTap: wc.minimize, tokens: tokens), + _WinButton(icon: const PhosphorIconPainter(0xe45e), onTap: wc.toggleMaximize, tokens: tokens), + _WinButton(icon: PhosphorIcons.xMark, onTap: wc.close, tokens: tokens, isClose: true), + ], + ); + } +} + +class _TrafficDot extends StatefulWidget { + const _TrafficDot({required this.color, required this.onTap}); + final Color color; + final VoidCallback onTap; + + @override + State<_TrafficDot> createState() => _TrafficDotState(); +} + +class _TrafficDotState extends State<_TrafficDot> { + bool _hover = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTap, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: _hover ? widget.color : widget.color.withAlpha(0xCC), + shape: BoxShape.circle, + ), + ), + ), + ); + } +} + +class _WinButton extends StatefulWidget { + const _WinButton({required this.icon, required this.onTap, required this.tokens, this.isClose = false}); + final ClideIconPainter icon; + final VoidCallback onTap; + final SurfaceTokens tokens; + final bool isClose; + + @override + State<_WinButton> createState() => _WinButtonState(); +} + +class _WinButtonState extends State<_WinButton> { + bool _hover = false; + + @override + Widget build(BuildContext context) { + final hoverBg = widget.isClose ? const Color(0xFFE81123) : widget.tokens.listItemHoverBackground; + return MouseRegion( + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTap, + child: Container( + width: 36, + height: hatHeight, + color: _hover ? hoverBg : null, + alignment: Alignment.center, + child: ClideIcon(widget.icon, size: 14, color: _hover && widget.isClose ? const Color(0xFFFFFFFF) : widget.tokens.globalTextMuted), + ), + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index b4254600..2af58d4c 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -6,6 +6,7 @@ library; export 'src/clide_button.dart'; +export 'src/clide_column_hat.dart'; export 'src/clide_divider.dart'; export 'src/clide_filter_box.dart'; export 'src/clide_icon.dart'; diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 159f9fe9..858b302f 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -25,32 +25,10 @@ static void my_application_activate(GApplication* application) { GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); - // Use a header bar when running in GNOME as this is the common style used - // by applications and is the setup most users will be using (e.g. Ubuntu - // desktop). - // If running on X and not using GNOME then just use a traditional title bar - // in case the window manager does more exotic layout, e.g. tiling. - // If running on Wayland assume the header bar will work (may need changing - // if future cases occur). - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "clide"); - gtk_header_bar_set_show_close_button(header_bar, TRUE); - gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); - } else { - gtk_window_set_title(window, "clide"); - } + // D-057: frameless custom chrome. The Flutter app draws its own + // per-column hats with drag regions and window buttons. + gtk_window_set_decorated(window, FALSE); + gtk_window_set_title(window, "clide"); gtk_window_set_default_size(window, 1280, 720); @@ -84,6 +62,53 @@ static void my_application_activate(GApplication* application) { fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + // D-057: method channel for window controls (drag, minimize, maximize, close). + FlEngine* engine = fl_view_get_engine(view); + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + FlMethodChannel* channel = fl_method_channel_new( + fl_engine_get_binary_messenger(engine), "clide/window", + FL_METHOD_CODEC(codec)); + g_object_set_data(G_OBJECT(window), "clide_method_channel", channel); + fl_method_channel_set_method_call_handler( + channel, + [](FlMethodChannel* channel, FlMethodCall* method_call, + gpointer user_data) { + GtkWindow* w = GTK_WINDOW(user_data); + const gchar* method = fl_method_call_get_name(method_call); + g_autoptr(FlMethodResponse) response = nullptr; + + if (g_strcmp0(method, "startDrag") == 0) { + gtk_window_begin_move_drag(w, 1, 0, 0, + GDK_CURRENT_TIME); + response = FL_METHOD_RESPONSE( + fl_method_success_response_new(fl_value_new_null())); + } else if (g_strcmp0(method, "minimize") == 0) { + gtk_window_iconify(w); + response = FL_METHOD_RESPONSE( + fl_method_success_response_new(fl_value_new_null())); + } else if (g_strcmp0(method, "maximize") == 0) { + if (gtk_window_is_maximized(w)) { + gtk_window_unmaximize(w); + } else { + gtk_window_maximize(w); + } + response = FL_METHOD_RESPONSE( + fl_method_success_response_new(fl_value_new_null())); + } else if (g_strcmp0(method, "close") == 0) { + gtk_window_close(w); + 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)))); + } else { + response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); + } + + fl_method_call_respond(method_call, response, nullptr); + }, + window, nullptr); + gtk_widget_grab_focus(GTK_WIDGET(view)); }