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) {
return ClideTappable(
onTap: () => kernel.commands.execute('theme.pick'),
builder: (ctx, hovered) => Row(
builder: (ctx, hovered, _) => Row(
mainAxisSize: MainAxisSize.min,
children: [
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/widgets/src/clide_tappable.dart';
import 'package:clide/widgets/src/clide_text.dart';
import 'package:flutter/widgets.dart';
enum ClideButtonVariant { normal, primary, subtle }
class ClideButton extends StatefulWidget {
class ClideButton extends StatelessWidget {
const ClideButton({
super.key,
required this.label,
@@ -13,92 +14,66 @@ class ClideButton extends StatefulWidget {
this.padding = const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
this.semanticLabel,
this.semanticHint,
this.tooltip,
});
final String label;
final VoidCallback? onPressed;
final ClideButtonVariant variant;
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;
/// Screen-reader hint describing the button's effect. Optional.
final String? semanticHint;
@override
State<ClideButton> createState() => _ClideButtonState();
}
class _ClideButtonState extends State<ClideButton> {
bool _hovered = false;
bool _pressed = false;
final String? tooltip;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final enabled = widget.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;
}
final enabled = onPressed != null;
return Semantics(
button: true,
enabled: enabled,
label: widget.semanticLabel ?? widget.label,
hint: widget.semanticHint,
onTap: enabled ? widget.onPressed : null,
label: semanticLabel ?? label,
hint: semanticHint,
onTap: enabled ? onPressed : null,
excludeSemantics: true,
child: MouseRegion(
cursor:
enabled ? SystemMouseCursors.click : SystemMouseCursors.forbidden,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTapDown: (_) => setState(() => _pressed = true),
onTapCancel: () => setState(() => _pressed = false),
onTapUp: (_) {
setState(() => _pressed = false);
widget.onPressed?.call();
},
child: Container(
padding: widget.padding,
child: ClideTappable(
cursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.forbidden,
tooltip: tooltip,
onTap: onPressed,
builder: (ctx, hovered, pressed) {
Color bg;
Color fg;
switch (variant) {
case ClideButtonVariant.normal:
bg = pressed
? tokens.buttonActiveBackground
: hovered
? tokens.buttonHoverBackground
: 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(
color: bg,
border: Border.all(color: tokens.buttonBorder, width: 1),
borderRadius: BorderRadius.circular(3),
),
child: ClideText(
widget.label,
label,
color: fg,
fontWeight: widget.variant == ClideButtonVariant.primary
? FontWeight.w600
: FontWeight.w500,
fontWeight: variant == ClideButtonVariant.primary ? FontWeight.w600 : FontWeight.w500,
),
),
),
);
},
),
);
}
+33 -56
View File
@@ -1,6 +1,6 @@
import 'package:clide/kernel/src/theme/controller.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';
class ClideIconRailItem {
@@ -15,7 +15,7 @@ class ClideIconRailItem {
final String tooltip;
}
class ClideIconRail extends StatefulWidget {
class ClideIconRail extends StatelessWidget {
const ClideIconRail({
super.key,
required this.items,
@@ -27,32 +27,20 @@ class ClideIconRail extends StatefulWidget {
final String? activeId;
final ValueChanged<String> onSelect;
@override
State<ClideIconRail> createState() => _ClideIconRailState();
}
class _ClideIconRailState extends State<ClideIconRail> {
String? _hoveredId;
@override
Widget build(BuildContext context) {
return MouseRegion(
onExit: (_) => setState(() => _hoveredId = null),
child: SizedBox(
width: double.infinity,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (final item in widget.items)
_RailButton(
item: item,
active: item.id == widget.activeId,
hovered: item.id == _hoveredId,
onHover: () => setState(() => _hoveredId = item.id),
onTap: () => widget.onSelect(item.id),
),
],
),
return SizedBox(
width: double.infinity,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (final item in items)
_RailButton(
item: item,
active: item.id == activeId,
onTap: () => onSelect(item.id),
),
],
),
);
}
@@ -62,53 +50,42 @@ class _RailButton extends StatelessWidget {
const _RailButton({
required this.item,
required this.active,
required this.hovered,
required this.onHover,
required this.onTap,
});
final ClideIconRailItem item;
final bool active;
final bool hovered;
final VoidCallback onHover;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final color = active
? tokens.globalForeground
: hovered
? tokens.sidebarForeground
: tokens.sidebarSectionHeader;
return Semantics(
button: true,
selected: active,
label: item.tooltip,
child: ClideTooltip(
message: item.tooltip,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => onHover(),
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: active
? tokens.tabActiveBorder
: const Color(0x00000000),
width: 2,
),
child: ClideTappable(
onTap: onTap,
tooltip: item.tooltip,
builder: (ctx, hovered, _) {
final color = active
? tokens.globalForeground
: hovered
? tokens.sidebarForeground
: tokens.sidebarSectionHeader;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: active ? tokens.tabActiveBorder : const Color(0x00000000),
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';
class ClideTappable extends StatefulWidget {
const ClideTappable({
super.key,
required this.onTap,
required this.builder,
this.onTap,
this.onLongPress,
this.onPressChanged,
this.cursor = SystemMouseCursors.click,
this.tooltip,
});
final VoidCallback onTap;
final Widget Function(BuildContext context, bool hovered) builder;
final Widget Function(BuildContext context, bool hovered, bool pressed) builder;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final ValueChanged<bool>? onPressChanged;
final MouseCursor cursor;
final String? tooltip;
@override
State<ClideTappable> createState() => _ClideTappableState();
@@ -18,18 +25,36 @@ class ClideTappable extends StatefulWidget {
class _ClideTappableState extends State<ClideTappable> {
bool _hover = false;
bool _pressed = false;
void _setPressed(bool v) {
if (_pressed == v) return;
setState(() => _pressed = v);
widget.onPressChanged?.call(v);
}
@override
Widget build(BuildContext context) {
return MouseRegion(
Widget child = MouseRegion(
cursor: widget.cursor,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
onExit: (_) {
setState(() => _hover = false);
_setPressed(false);
},
child: GestureDetector(
behavior: HitTestBehavior.opaque,
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: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 {
const ClideTooltip({
super.key,
@@ -30,22 +28,37 @@ class _ClideTooltipState extends State<ClideTooltip> {
_entry?.remove();
final box = context.findRenderObject() as RenderBox?;
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(
builder: (ctx) {
final tokens = ClideTheme.of(ctx).surface;
return Positioned(
left: offset.dx,
top: offset.dy,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: tokens.tooltipBackground,
border: Border.all(color: tokens.tooltipBorder),
borderRadius: BorderRadius.circular(3),
),
child: ClideText(widget.message, color: tokens.tooltipForeground),
final tooltip = Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: tokens.tooltipBackground,
border: Border.all(color: tokens.tooltipBorder),
borderRadius: BorderRadius.circular(3),
),
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,
);
},
);