diff --git a/lib/builtin/welcome/src/welcome_view.dart b/lib/builtin/welcome/src/welcome_view.dart index 73f1c145..17681353 100644 --- a/lib/builtin/welcome/src/welcome_view.dart +++ b/lib/builtin/welcome/src/welcome_view.dart @@ -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), diff --git a/lib/widgets/src/clide_button.dart b/lib/widgets/src/clide_button.dart index 0aee3ad9..f0bb025b 100644 --- a/lib/widgets/src/clide_button.dart +++ b/lib/widgets/src/clide_button.dart @@ -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 createState() => _ClideButtonState(); -} - -class _ClideButtonState extends State { - 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, ), - ), - ), + ); + }, ), ); } diff --git a/lib/widgets/src/clide_icon_rail.dart b/lib/widgets/src/clide_icon_rail.dart index ff3ff30e..74b4e452 100644 --- a/lib/widgets/src/clide_icon_rail.dart +++ b/lib/widgets/src/clide_icon_rail.dart @@ -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 onSelect; - @override - State createState() => _ClideIconRailState(); -} - -class _ClideIconRailState extends State { - 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), + ); + }, ), ); } diff --git a/lib/widgets/src/clide_tappable.dart b/lib/widgets/src/clide_tappable.dart index 9459ecae..11ca0e6c 100644 --- a/lib/widgets/src/clide_tappable.dart +++ b/lib/widgets/src/clide_tappable.dart @@ -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? onPressChanged; final MouseCursor cursor; + final String? tooltip; @override State createState() => _ClideTappableState(); @@ -18,18 +25,36 @@ class ClideTappable extends StatefulWidget { class _ClideTappableState extends State { 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; } } diff --git a/lib/widgets/src/clide_tooltip.dart b/lib/widgets/src/clide_tooltip.dart index 34aa4687..13e80fa9 100644 --- a/lib/widgets/src/clide_tooltip.dart +++ b/lib/widgets/src/clide_tooltip.dart @@ -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 { _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, ); }, );