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) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 08:16:28 +02:00
co-authored by Claude Opus 4.6
parent eb8c9ecf5d
commit 538472f7ec
9 changed files with 325 additions and 31 deletions
+9
View File
@@ -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.
---
+2 -2
View File
@@ -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
+16 -3
View File
@@ -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)),
]),
),
],
],
+1
View File
@@ -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';
+5
View File
@@ -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<KernelServices> 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,
);
}
+58
View File
@@ -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<void> startDrag() async {
try {
await _channel.invokeMethod('startDrag');
} on MissingPluginException {
// Web or unsupported platform — no-op.
}
}
Future<void> minimize() async {
try {
await _channel.invokeMethod('minimize');
} on MissingPluginException {
// no-op
}
}
Future<void> toggleMaximize() async {
try {
await _channel.invokeMethod('maximize');
} on MissingPluginException {
// no-op
}
}
Future<void> close() async {
try {
await _channel.invokeMethod('close');
} on MissingPluginException {
// no-op
}
}
Future<bool> isMaximized() async {
try {
final result = await _channel.invokeMethod<bool>('isMaximized');
return result ?? false;
} on MissingPluginException {
return false;
}
}
}
+182
View File
@@ -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 = <String>[];
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),
),
),
);
}
}
+1
View File
@@ -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';
+51 -26
View File
@@ -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));
}