diff --git a/lib/shared/widgets/querya_button.dart b/lib/shared/widgets/querya_button.dart index 109670b0..48d12106 100644 --- a/lib/shared/widgets/querya_button.dart +++ b/lib/shared/widgets/querya_button.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' as material; +import 'package:flutter/rendering.dart' show RenderObject, RenderProxyBox; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; @@ -293,6 +294,40 @@ class _QueryaButtonState extends material.State { } + /// Whether the label was cut short at the last layout (#1689). + bool _labelCut = false; + + void _onLabelCut(bool cut) { + if (mounted && cut != _labelCut) setState(() => _labelCut = cut); + } + + /// The label on one line with an ellipsis. With [showWhenCut] it reports + /// after each layout whether it fit, and a label that did not gets a tooltip + /// with its full text, so a button that fits shows none. No + /// [material.LayoutBuilder]: buttons sit in intrinsic-size layouts. + material.Widget _label( + String label, + material.TextStyle style, { + required bool showWhenCut, + }) { + final text = material.Text( + label, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + softWrap: false, + style: style, + ); + if (!showWhenCut) return text; + final reported = _CutReport(onChanged: _onLabelCut, child: text); + if (!_labelCut) return reported; + return material.Tooltip( + message: label, + waitDuration: kQueryaTooltipWait, + excludeFromSemantics: true, + child: reported, + ); + } + @override material.Widget build(material.BuildContext context) { final w = widget; @@ -370,17 +405,17 @@ class _QueryaButtonState extends material.State { if (!iconOnly) ...[ if (leading != null) material.SizedBox(width: gap), material.Flexible( - child: material.Text( + child: _label( w.label!, - maxLines: 1, - overflow: material.TextOverflow.ellipsis, - softWrap: false, - style: material.TextStyle( + material.TextStyle( fontSize: fontSize, fontWeight: material.FontWeight.w500, height: 1.2, color: foreground, ), + // An explicit tooltip wins; otherwise a label cut short by a + // narrow toolbar shows in full on hover (#1689). + showWhenCut: w.tooltip == null || w.tooltip!.isEmpty, ), ), if (w.shortcutHint != null) ...[ @@ -535,3 +570,43 @@ class _QueryaButtonState extends material.State { ); } } + +/// Lays its text child out as usual and reports, after the frame, whether the +/// text was cut: its full one-line width is more than the width it got. +class _CutReport extends material.SingleChildRenderObjectWidget { + const _CutReport({required this.onChanged, required super.child}); + + final void Function(bool cut) onChanged; + + @override + RenderObject createRenderObject(material.BuildContext context) => + _RenderCutReport(onChanged); + + @override + void updateRenderObject( + material.BuildContext context, + _RenderCutReport renderObject, + ) { + renderObject.onChanged = onChanged; + } +} + +class _RenderCutReport extends RenderProxyBox { + _RenderCutReport(this.onChanged); + + void Function(bool cut) onChanged; + bool? _reported; + + @override + void performLayout() { + super.performLayout(); + final text = child; + if (text == null) return; + final cut = text.getMaxIntrinsicWidth(double.infinity) > size.width + 0.5; + if (cut == _reported) return; + _reported = cut; + // Not during layout: the owner rebuilds with or without a tooltip. + final report = onChanged; + material.WidgetsBinding.instance.addPostFrameCallback((_) => report(cut)); + } +} diff --git a/test/shared/widgets/querya_button_test.dart b/test/shared/widgets/querya_button_test.dart index ff130e7b..305d283c 100644 --- a/test/shared/widgets/querya_button_test.dart +++ b/test/shared/widgets/querya_button_test.dart @@ -277,4 +277,76 @@ void main() { isFalse, ); }); + + group('QueryaButton truncated label (#1689)', () { + testWidgets('a button inside an intrinsic-width layout still lays out', + (tester) async { + await tester.pumpWidget(_shell(material.IntrinsicWidth( + child: QueryaButton.secondary(label: 'Fits', onPressed: () {}), + ))); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.text('Fits'), findsOneWidget); + }); + + testWidgets('widening the space removes the tooltip again', (tester) async { + Future at(double width) async { + await tester.pumpWidget(_shell(material.SizedBox( + width: width, + child: QueryaButton.secondary( + label: 'Export the current result set as CSV', + onPressed: () {}, + ), + ))); + await tester.pump(); + } + + await at(90); + expect(find.byType(material.Tooltip), findsOneWidget); + await at(600); + expect(find.byType(material.Tooltip), findsNothing); + }); + + const long = 'Export the current result set as CSV'; + Finder tooltipWith(String message) => find.byWidgetPredicate( + (w) => w is material.Tooltip && w.message == message, + ); + + testWidgets('a label cut short shows its full text as a tooltip', + (tester) async { + await tester.pumpWidget(_shell(material.SizedBox( + width: 90, + child: QueryaButton.secondary(label: long, onPressed: () {}), + ))); + await tester.pump(); // the cut is reported after the first frame + + expect(tooltipWith(long), findsOneWidget); + }); + + testWidgets('a label that fits gets no tooltip', (tester) async { + await tester.pumpWidget(_shell(material.SizedBox( + width: 240, + child: QueryaButton.secondary(label: 'Save', onPressed: () {}), + ))); + await tester.pump(); + + expect(find.byType(material.Tooltip), findsNothing); + }); + + testWidgets('an explicit tooltip wins over the cut label', (tester) async { + await tester.pumpWidget(_shell(material.SizedBox( + width: 90, + child: QueryaButton.secondary( + label: long, + tooltip: 'Export as CSV', + onPressed: () {}, + ), + ))); + await tester.pump(); + + expect(tooltipWith(long), findsNothing); + expect(tooltipWith('Export as CSV'), findsOneWidget); + }); + }); }