From ec9f46673c4c0b43742be0d2710dc9f8975c6268 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 12:31:31 +0300 Subject: [PATCH 1/3] ux(button): a QueryaButton label cut short shows its full text as a tooltip (#1689) --- lib/shared/widgets/querya_button.dart | 53 +++++++++++++++++++-- test/shared/widgets/querya_button_test.dart | 40 ++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/lib/shared/widgets/querya_button.dart b/lib/shared/widgets/querya_button.dart index 109670b0..d7373d30 100644 --- a/lib/shared/widgets/querya_button.dart +++ b/lib/shared/widgets/querya_button.dart @@ -293,6 +293,48 @@ class _QueryaButtonState extends material.State { } + /// The label on one line with an ellipsis. With [showWhenCut] it is + /// measured against the width it gets, and only a label that does not fit + /// gets a tooltip with its full text, so a button that fits shows none. + static material.Widget _label( + material.BuildContext context, + 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; + return material.LayoutBuilder( + builder: (context, constraints) { + if (!constraints.hasBoundedWidth) return text; + final painter = material.TextPainter( + text: material.TextSpan( + text: label, + style: material.DefaultTextStyle.of(context).style.merge(style), + ), + maxLines: 1, + textDirection: material.Directionality.of(context), + textScaler: material.MediaQuery.textScalerOf(context), + )..layout(); + final cut = painter.width > constraints.maxWidth + 0.5; + painter.dispose(); + if (!cut) return text; + return material.Tooltip( + message: label, + waitDuration: kQueryaTooltipWait, + excludeFromSemantics: true, + child: text, + ); + }, + ); + } + @override material.Widget build(material.BuildContext context) { final w = widget; @@ -370,17 +412,18 @@ class _QueryaButtonState extends material.State { if (!iconOnly) ...[ if (leading != null) material.SizedBox(width: gap), material.Flexible( - child: material.Text( + child: _label( + context, 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) ...[ diff --git a/test/shared/widgets/querya_button_test.dart b/test/shared/widgets/querya_button_test.dart index ff130e7b..ff197c9e 100644 --- a/test/shared/widgets/querya_button_test.dart +++ b/test/shared/widgets/querya_button_test.dart @@ -277,4 +277,44 @@ void main() { isFalse, ); }); + + group('QueryaButton truncated label (#1689)', () { + 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: () {}), + ))); + + 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: () {}), + ))); + + 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: () {}, + ), + ))); + + expect(tooltipWith(long), findsNothing); + expect(tooltipWith('Export as CSV'), findsOneWidget); + }); + }); } From dfcf927ccde7aea9b25f350cb26bd8da5b2030a1 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 12:33:29 +0300 Subject: [PATCH 2/3] ux(button): report a cut label from a render proxy, not a LayoutBuilder, so buttons keep working in intrinsic layouts --- lib/shared/widgets/querya_button.dart | 87 ++++++++++++++------- test/shared/widgets/querya_button_test.dart | 32 ++++++++ 2 files changed, 91 insertions(+), 28 deletions(-) diff --git a/lib/shared/widgets/querya_button.dart b/lib/shared/widgets/querya_button.dart index d7373d30..84b5051e 100644 --- a/lib/shared/widgets/querya_button.dart +++ b/lib/shared/widgets/querya_button.dart @@ -293,11 +293,18 @@ class _QueryaButtonState extends material.State { } - /// The label on one line with an ellipsis. With [showWhenCut] it is - /// measured against the width it gets, and only a label that does not fit - /// gets a tooltip with its full text, so a button that fits shows none. - static material.Widget _label( - material.BuildContext context, + /// 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, @@ -310,28 +317,13 @@ class _QueryaButtonState extends material.State { style: style, ); if (!showWhenCut) return text; - return material.LayoutBuilder( - builder: (context, constraints) { - if (!constraints.hasBoundedWidth) return text; - final painter = material.TextPainter( - text: material.TextSpan( - text: label, - style: material.DefaultTextStyle.of(context).style.merge(style), - ), - maxLines: 1, - textDirection: material.Directionality.of(context), - textScaler: material.MediaQuery.textScalerOf(context), - )..layout(); - final cut = painter.width > constraints.maxWidth + 0.5; - painter.dispose(); - if (!cut) return text; - return material.Tooltip( - message: label, - waitDuration: kQueryaTooltipWait, - excludeFromSemantics: true, - child: text, - ); - }, + final reported = _CutReport(onChanged: _onLabelCut, child: text); + if (!_labelCut) return reported; + return material.Tooltip( + message: label, + waitDuration: kQueryaTooltipWait, + excludeFromSemantics: true, + child: reported, ); } @@ -413,7 +405,6 @@ class _QueryaButtonState extends material.State { if (leading != null) material.SizedBox(width: gap), material.Flexible( child: _label( - context, w.label!, material.TextStyle( fontSize: fontSize, @@ -578,3 +569,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 + material.RenderObject createRenderObject(material.BuildContext context) => + _RenderCutReport(onChanged); + + @override + void updateRenderObject( + material.BuildContext context, + _RenderCutReport renderObject, + ) { + renderObject.onChanged = onChanged; + } +} + +class _RenderCutReport extends material.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 ff197c9e..305d283c 100644 --- a/test/shared/widgets/querya_button_test.dart +++ b/test/shared/widgets/querya_button_test.dart @@ -279,6 +279,35 @@ void main() { }); 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, @@ -290,6 +319,7 @@ void main() { width: 90, child: QueryaButton.secondary(label: long, onPressed: () {}), ))); + await tester.pump(); // the cut is reported after the first frame expect(tooltipWith(long), findsOneWidget); }); @@ -299,6 +329,7 @@ void main() { width: 240, child: QueryaButton.secondary(label: 'Save', onPressed: () {}), ))); + await tester.pump(); expect(find.byType(material.Tooltip), findsNothing); }); @@ -312,6 +343,7 @@ void main() { onPressed: () {}, ), ))); + await tester.pump(); expect(tooltipWith(long), findsNothing); expect(tooltipWith('Export as CSV'), findsOneWidget); From 0126485b431afa7c87ab994d2156dfeca4ba5dca Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 12:39:25 +0300 Subject: [PATCH 3/3] fix(button): import the render proxy types from rendering --- lib/shared/widgets/querya_button.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/shared/widgets/querya_button.dart b/lib/shared/widgets/querya_button.dart index 84b5051e..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'; @@ -578,7 +579,7 @@ class _CutReport extends material.SingleChildRenderObjectWidget { final void Function(bool cut) onChanged; @override - material.RenderObject createRenderObject(material.BuildContext context) => + RenderObject createRenderObject(material.BuildContext context) => _RenderCutReport(onChanged); @override @@ -590,7 +591,7 @@ class _CutReport extends material.SingleChildRenderObjectWidget { } } -class _RenderCutReport extends material.RenderProxyBox { +class _RenderCutReport extends RenderProxyBox { _RenderCutReport(this.onChanged); void Function(bool cut) onChanged;