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:
+16
-3
@@ -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)),
|
||||
]),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user