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