extend ClideTappable with press/longpress, retrofit button + rail

ClideTappable builder now receives (context, hovered, pressed).
Optional onLongPress and onPressChanged callbacks. ClideButton
is now a StatelessWidget wrapping ClideTappable. ClideIconRail
refactored — _RailButton uses ClideTappable with tooltip.
ClideTooltip positions smart (above when near screen bottom).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 11:21:05 +02:00
co-authored by Claude Opus 4.6
parent c9870bb044
commit fbd5f1ae04
5 changed files with 129 additions and 139 deletions
+1 -1
View File
@@ -307,7 +307,7 @@ class _ThemeLink extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ClideTappable( return ClideTappable(
onTap: () => kernel.commands.execute('theme.pick'), onTap: () => kernel.commands.execute('theme.pick'),
builder: (ctx, hovered) => Row( builder: (ctx, hovered, _) => Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ClideText('theme: ', muted: true, fontSize: 12, fontFamily: clideMonoFamily), ClideText('theme: ', muted: true, fontSize: 12, fontFamily: clideMonoFamily),
+37 -62
View File
@@ -1,10 +1,11 @@
import 'package:clide/kernel/src/theme/controller.dart'; import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/widgets/src/clide_tappable.dart';
import 'package:clide/widgets/src/clide_text.dart'; import 'package:clide/widgets/src/clide_text.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
enum ClideButtonVariant { normal, primary, subtle } enum ClideButtonVariant { normal, primary, subtle }
class ClideButton extends StatefulWidget { class ClideButton extends StatelessWidget {
const ClideButton({ const ClideButton({
super.key, super.key,
required this.label, required this.label,
@@ -13,92 +14,66 @@ class ClideButton extends StatefulWidget {
this.padding = const EdgeInsets.symmetric(horizontal: 12, vertical: 6), this.padding = const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
this.semanticLabel, this.semanticLabel,
this.semanticHint, this.semanticHint,
this.tooltip,
}); });
final String label; final String label;
final VoidCallback? onPressed; final VoidCallback? onPressed;
final ClideButtonVariant variant; final ClideButtonVariant variant;
final EdgeInsetsGeometry padding; final EdgeInsetsGeometry padding;
/// Overrides [label] for screen readers (use when the visible label is
/// an icon-only glyph or a noun that reads oddly when announced).
final String? semanticLabel; final String? semanticLabel;
/// Screen-reader hint describing the button's effect. Optional.
final String? semanticHint; final String? semanticHint;
final String? tooltip;
@override
State<ClideButton> createState() => _ClideButtonState();
}
class _ClideButtonState extends State<ClideButton> {
bool _hovered = false;
bool _pressed = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface; final tokens = ClideTheme.of(context).surface;
final enabled = widget.onPressed != null; final enabled = onPressed != null;
Color bg;
Color fg;
switch (widget.variant) {
case ClideButtonVariant.normal:
bg = _pressed
? tokens.buttonActiveBackground
: _hovered
? tokens.buttonHoverBackground
: tokens.buttonBackground;
fg = tokens.buttonForeground;
case ClideButtonVariant.primary:
bg = _pressed
? tokens.panelActiveBorder
: _hovered
? tokens.buttonActiveBackground
: tokens.buttonActiveBackground;
fg = tokens.globalBackground;
case ClideButtonVariant.subtle:
bg = _hovered
? tokens.listItemHoverBackground
: tokens.listItemBackground;
fg = tokens.listItemForeground;
}
return Semantics( return Semantics(
button: true, button: true,
enabled: enabled, enabled: enabled,
label: widget.semanticLabel ?? widget.label, label: semanticLabel ?? label,
hint: widget.semanticHint, hint: semanticHint,
onTap: enabled ? widget.onPressed : null, onTap: enabled ? onPressed : null,
excludeSemantics: true, excludeSemantics: true,
child: MouseRegion( child: ClideTappable(
cursor: cursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.forbidden,
enabled ? SystemMouseCursors.click : SystemMouseCursors.forbidden, tooltip: tooltip,
onEnter: (_) => setState(() => _hovered = true), onTap: onPressed,
onExit: (_) => setState(() => _hovered = false), builder: (ctx, hovered, pressed) {
child: GestureDetector( Color bg;
onTapDown: (_) => setState(() => _pressed = true), Color fg;
onTapCancel: () => setState(() => _pressed = false), switch (variant) {
onTapUp: (_) { case ClideButtonVariant.normal:
setState(() => _pressed = false); bg = pressed
widget.onPressed?.call(); ? tokens.buttonActiveBackground
}, : hovered
child: Container( ? tokens.buttonHoverBackground
padding: widget.padding, : tokens.buttonBackground;
fg = tokens.buttonForeground;
case ClideButtonVariant.primary:
bg = pressed ? tokens.panelActiveBorder : tokens.buttonActiveBackground;
fg = tokens.globalBackground;
case ClideButtonVariant.subtle:
bg = hovered ? tokens.listItemHoverBackground : tokens.listItemBackground;
fg = tokens.listItemForeground;
}
return Container(
padding: padding,
decoration: BoxDecoration( decoration: BoxDecoration(
color: bg, color: bg,
border: Border.all(color: tokens.buttonBorder, width: 1), border: Border.all(color: tokens.buttonBorder, width: 1),
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
), ),
child: ClideText( child: ClideText(
widget.label, label,
color: fg, color: fg,
fontWeight: widget.variant == ClideButtonVariant.primary fontWeight: variant == ClideButtonVariant.primary ? FontWeight.w600 : FontWeight.w500,
? FontWeight.w600
: FontWeight.w500,
), ),
), );
), },
), ),
); );
} }
+33 -56
View File
@@ -1,6 +1,6 @@
import 'package:clide/kernel/src/theme/controller.dart'; import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/widgets/src/clide_icon.dart'; import 'package:clide/widgets/src/clide_icon.dart';
import 'package:clide/widgets/src/clide_tooltip.dart'; import 'package:clide/widgets/src/clide_tappable.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class ClideIconRailItem { class ClideIconRailItem {
@@ -15,7 +15,7 @@ class ClideIconRailItem {
final String tooltip; final String tooltip;
} }
class ClideIconRail extends StatefulWidget { class ClideIconRail extends StatelessWidget {
const ClideIconRail({ const ClideIconRail({
super.key, super.key,
required this.items, required this.items,
@@ -27,32 +27,20 @@ class ClideIconRail extends StatefulWidget {
final String? activeId; final String? activeId;
final ValueChanged<String> onSelect; final ValueChanged<String> onSelect;
@override
State<ClideIconRail> createState() => _ClideIconRailState();
}
class _ClideIconRailState extends State<ClideIconRail> {
String? _hoveredId;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MouseRegion( return SizedBox(
onExit: (_) => setState(() => _hoveredId = null), width: double.infinity,
child: SizedBox( child: Row(
width: double.infinity, mainAxisAlignment: MainAxisAlignment.center,
child: Row( children: [
mainAxisAlignment: MainAxisAlignment.center, for (final item in items)
children: [ _RailButton(
for (final item in widget.items) item: item,
_RailButton( active: item.id == activeId,
item: item, onTap: () => onSelect(item.id),
active: item.id == widget.activeId, ),
hovered: item.id == _hoveredId, ],
onHover: () => setState(() => _hoveredId = item.id),
onTap: () => widget.onSelect(item.id),
),
],
),
), ),
); );
} }
@@ -62,53 +50,42 @@ class _RailButton extends StatelessWidget {
const _RailButton({ const _RailButton({
required this.item, required this.item,
required this.active, required this.active,
required this.hovered,
required this.onHover,
required this.onTap, required this.onTap,
}); });
final ClideIconRailItem item; final ClideIconRailItem item;
final bool active; final bool active;
final bool hovered;
final VoidCallback onHover;
final VoidCallback onTap; final VoidCallback onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface; final tokens = ClideTheme.of(context).surface;
final color = active
? tokens.globalForeground
: hovered
? tokens.sidebarForeground
: tokens.sidebarSectionHeader;
return Semantics( return Semantics(
button: true, button: true,
selected: active, selected: active,
label: item.tooltip, label: item.tooltip,
child: ClideTooltip( child: ClideTappable(
message: item.tooltip, onTap: onTap,
child: MouseRegion( tooltip: item.tooltip,
cursor: SystemMouseCursors.click, builder: (ctx, hovered, _) {
onEnter: (_) => onHover(), final color = active
child: GestureDetector( ? tokens.globalForeground
onTap: onTap, : hovered
child: Container( ? tokens.sidebarForeground
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), : tokens.sidebarSectionHeader;
decoration: BoxDecoration( return Container(
border: Border( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
bottom: BorderSide( decoration: BoxDecoration(
color: active border: Border(
? tokens.tabActiveBorder bottom: BorderSide(
: const Color(0x00000000), color: active ? tokens.tabActiveBorder : const Color(0x00000000),
width: 2, width: 2,
),
), ),
), ),
child: ClideIcon(item.icon, size: 16, color: color),
), ),
), child: ClideIcon(item.icon, size: 16, color: color),
), );
},
), ),
); );
} }
+31 -6
View File
@@ -1,16 +1,23 @@
import 'package:clide/widgets/src/clide_tooltip.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class ClideTappable extends StatefulWidget { class ClideTappable extends StatefulWidget {
const ClideTappable({ const ClideTappable({
super.key, super.key,
required this.onTap,
required this.builder, required this.builder,
this.onTap,
this.onLongPress,
this.onPressChanged,
this.cursor = SystemMouseCursors.click, this.cursor = SystemMouseCursors.click,
this.tooltip,
}); });
final VoidCallback onTap; final Widget Function(BuildContext context, bool hovered, bool pressed) builder;
final Widget Function(BuildContext context, bool hovered) builder; final VoidCallback? onTap;
final VoidCallback? onLongPress;
final ValueChanged<bool>? onPressChanged;
final MouseCursor cursor; final MouseCursor cursor;
final String? tooltip;
@override @override
State<ClideTappable> createState() => _ClideTappableState(); State<ClideTappable> createState() => _ClideTappableState();
@@ -18,18 +25,36 @@ class ClideTappable extends StatefulWidget {
class _ClideTappableState extends State<ClideTappable> { class _ClideTappableState extends State<ClideTappable> {
bool _hover = false; bool _hover = false;
bool _pressed = false;
void _setPressed(bool v) {
if (_pressed == v) return;
setState(() => _pressed = v);
widget.onPressChanged?.call(v);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MouseRegion( Widget child = MouseRegion(
cursor: widget.cursor, cursor: widget.cursor,
onEnter: (_) => setState(() => _hover = true), onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false), onExit: (_) {
setState(() => _hover = false);
_setPressed(false);
},
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: widget.onTap, onTap: widget.onTap,
child: widget.builder(context, _hover), onLongPress: widget.onLongPress,
onTapDown: (_) => _setPressed(true),
onTapUp: (_) => _setPressed(false),
onTapCancel: () => _setPressed(false),
child: widget.builder(context, _hover, _pressed),
), ),
); );
if (widget.tooltip != null) {
child = ClideTooltip(message: widget.tooltip!, child: child);
}
return child;
} }
} }
+27 -14
View File
@@ -2,8 +2,6 @@ import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/widgets/src/clide_text.dart'; import 'package:clide/widgets/src/clide_text.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
/// Very small tooltip — hover a child to reveal a label. Uses an
/// OverlayEntry to draw above everything else without Material.
class ClideTooltip extends StatefulWidget { class ClideTooltip extends StatefulWidget {
const ClideTooltip({ const ClideTooltip({
super.key, super.key,
@@ -30,22 +28,37 @@ class _ClideTooltipState extends State<ClideTooltip> {
_entry?.remove(); _entry?.remove();
final box = context.findRenderObject() as RenderBox?; final box = context.findRenderObject() as RenderBox?;
if (box == null) return; if (box == null) return;
final offset = box.localToGlobal(Offset(0, box.size.height + 4)); final target = box.localToGlobal(Offset.zero);
final size = box.size;
final screenSize = MediaQuery.of(context).size;
_entry = OverlayEntry( _entry = OverlayEntry(
builder: (ctx) { builder: (ctx) {
final tokens = ClideTheme.of(ctx).surface; final tokens = ClideTheme.of(ctx).surface;
return Positioned( final tooltip = Container(
left: offset.dx, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
top: offset.dy, decoration: BoxDecoration(
child: Container( color: tokens.tooltipBackground,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), border: Border.all(color: tokens.tooltipBorder),
decoration: BoxDecoration( borderRadius: BorderRadius.circular(3),
color: tokens.tooltipBackground,
border: Border.all(color: tokens.tooltipBorder),
borderRadius: BorderRadius.circular(3),
),
child: ClideText(widget.message, color: tokens.tooltipForeground),
), ),
child: ClideText(widget.message, color: tokens.tooltipForeground, fontSize: 12),
);
final spaceBelow = screenSize.height - target.dy - size.height;
final showAbove = spaceBelow < 60;
if (showAbove) {
return Positioned(
left: target.dx,
bottom: screenSize.height - target.dy + 4,
child: tooltip,
);
}
return Positioned(
left: target.dx,
top: target.dy + size.height + 4,
child: tooltip,
); );
}, },
); );