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