diff --git a/lib/core/ui/querya_control_tokens.dart b/lib/core/ui/querya_control_tokens.dart new file mode 100644 index 00000000..76a2b0ed --- /dev/null +++ b/lib/core/ui/querya_control_tokens.dart @@ -0,0 +1,114 @@ +import 'package:flutter/widgets.dart'; +import 'package:querya_desktop/core/layout/ui_scale.dart'; + +/// The raw numbers of the control scale (#1333). Constants so other token +/// classes can build `const` values from them; widgets read them through +/// [QueryaControlSize]. +abstract final class QueryaControlMetrics { + static const double heightSm = 28; + static const double heightMd = 32; + static const double heightLg = 36; + + static const double fontSm = 12; + static const double fontMd = 13; + static const double fontLg = 14; + + static const double iconSm = 15; + static const double iconMd = 16; + static const double iconLg = 18; + + /// One corner radius for buttons, icon buttons, dropdown triggers, search + /// fields and tab pills. + static const double radius = 6; +} + +/// One of the three control sizes of the Querya UI kit. Buttons, icon buttons, +/// dropdown triggers, search fields and tab strips of one size share a height, +/// so a toolbar row built from them has one height (docs/ui-kit.md). +/// +/// Values are logical px at `uiScale` 1; the `scaled*` helpers apply the +/// app-wide UI scale. +enum QueryaControlSize { + /// Tree headers, grid and panel toolbars, dense rows. + sm( + height: QueryaControlMetrics.heightSm, + horizontalPadding: 8, + fontSize: QueryaControlMetrics.fontSm, + iconSize: QueryaControlMetrics.iconSm, + iconGap: 6, + ), + + /// Top toolbars, editor toolbars, ERD, result bars. + md( + height: QueryaControlMetrics.heightMd, + horizontalPadding: 12, + fontSize: QueryaControlMetrics.fontMd, + iconSize: QueryaControlMetrics.iconMd, + iconGap: 6, + ), + + /// Dialog footers, forms, empty-state actions. + lg( + height: QueryaControlMetrics.heightLg, + horizontalPadding: 16, + fontSize: QueryaControlMetrics.fontLg, + iconSize: QueryaControlMetrics.iconLg, + iconGap: 8, + ); + + const QueryaControlSize({ + required this.height, + required this.horizontalPadding, + required this.fontSize, + required this.iconSize, + required this.iconGap, + }); + + final double height; + final double horizontalPadding; + final double fontSize; + final double iconSize; + final double iconGap; + + /// Corner radius; the same for every size. + double get radius => QueryaControlMetrics.radius; + + double scaledHeight(BuildContext context) => context.scaled(height); + + double scaledHorizontalPadding(BuildContext context) => + context.scaled(horizontalPadding); + + double scaledFontSize(BuildContext context) => context.scaled(fontSize); + + double scaledIconSize(BuildContext context) => context.scaled(iconSize); + + double scaledIconGap(BuildContext context) => context.scaled(iconGap); + + double scaledRadius(BuildContext context) => context.scaled(radius); + + /// The size a control without an explicit one uses here: the nearest + /// [QueryaControlScope], or [md]. + static QueryaControlSize of(BuildContext context) => + QueryaControlScope.maybeOf(context) ?? QueryaControlSize.md; +} + +/// Sets the default [QueryaControlSize] of a subtree: a toolbar says its size +/// once and the controls in it follow. A control's own `size` still wins. +class QueryaControlScope extends InheritedWidget { + const QueryaControlScope({ + super.key, + required this.size, + required super.child, + }); + + final QueryaControlSize size; + + /// The size set by the nearest scope, or null when there is none. + static QueryaControlSize? maybeOf(BuildContext context) => context + .dependOnInheritedWidgetOfExactType() + ?.size; + + @override + bool updateShouldNotify(QueryaControlScope oldWidget) => + size != oldWidget.size; +} diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index e8f889dc..3ac22a32 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; +import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -47,6 +48,7 @@ class QueryaDropdown extends material.StatefulWidget { this.menuMaxHeight = QueryaDropdownTokens.menuMaxHeight, this.hint, this.compact = false, + this.size, }); final T value; @@ -60,9 +62,15 @@ class QueryaDropdown extends material.StatefulWidget { final double menuMaxHeight; final String? hint; - /// Smaller trigger and rows for toolbars / panel headers. + /// Smaller trigger and rows for toolbars / panel headers (same as + /// [QueryaControlSize.sm]). final bool compact; + /// Control size of the trigger. Without one: [QueryaControlSize.sm] when + /// [compact], else the nearest [QueryaControlScope], else + /// [QueryaControlSize.lg]. + final QueryaControlSize? size; + @override material.State> createState() => _QueryaDropdownState(); } @@ -75,6 +83,7 @@ class _QueryaDropdownState extends material.State> { List? _cachedMenuChildren; List>? _cachedMenuItems; T? _cachedMenuValue; + QueryaControlSize? _cachedMenuSize; var _closingWithExit = false; var _exitGeneration = 0; @@ -122,21 +131,35 @@ class _QueryaDropdownState extends material.State> { _controller = widget.controller ?? material.MenuController(); } if (!listEquals(oldWidget.items, widget.items) || - oldWidget.value != widget.value) { + oldWidget.value != widget.value || + oldWidget.size != widget.size || + oldWidget.compact != widget.compact) { _cachedMenuChildren = null; } } - List _menuChildren(ColorScheme cs) { + /// The size this dropdown draws at: its own [QueryaDropdown.size], else + /// [QueryaControlSize.sm] when compact, else the nearest control scope, else + /// [QueryaControlSize.lg]. + QueryaControlSize _controlSize(material.BuildContext context) { + final explicit = widget.size; + if (explicit != null) return explicit; + if (widget.compact) return QueryaControlSize.sm; + return QueryaControlScope.maybeOf(context) ?? QueryaControlSize.lg; + } + + List _menuChildren(ColorScheme cs, QueryaControlSize size) { if (_cachedMenuChildren != null && listEquals(_cachedMenuItems, widget.items) && - _cachedMenuValue == widget.value) { + _cachedMenuValue == widget.value && + _cachedMenuSize == size) { return _cachedMenuChildren!; } _cachedMenuItems = List>.from(widget.items); _cachedMenuValue = widget.value; + _cachedMenuSize = size; _cachedMenuChildren = [ - for (final item in widget.items) _menuItem(item, cs), + for (final item in widget.items) _menuItem(item, cs, size), ]; return _cachedMenuChildren!; } @@ -146,6 +169,7 @@ class _QueryaDropdownState extends material.State> { required String label, required ColorScheme cs, required bool expand, + required QueryaControlSize size, }) { final textColor = widget.enabled ? cs.popoverForeground : cs.mutedForeground; @@ -157,6 +181,7 @@ class _QueryaDropdownState extends material.State> { context, textColor, compact: widget.compact, + size: size, ), ); if (expand) { @@ -172,13 +197,18 @@ class _QueryaDropdownState extends material.State> { return widget.hint ?? ''; } - material.Widget _menuItem(QueryaDropdownItem item, ColorScheme cs) { + material.Widget _menuItem( + QueryaDropdownItem item, + ColorScheme cs, + QueryaControlSize size, + ) { return _QueryaDropdownMenuItem( item: item, selected: item.value == widget.value, enabled: widget.enabled && item.enabled, colorScheme: cs, compact: widget.compact, + size: size, onPick: () { widget.onSelected(item.value); unawaited(_closeWithExit()); @@ -196,9 +226,11 @@ class _QueryaDropdownState extends material.State> { final borderColor = widget.enabled ? (_triggerHovered || _triggerFocused ? cs.ring : cs.border) : cs.border.withValues(alpha: 0.4); + final size = _controlSize(context); final triggerHeight = QueryaDropdownTokens.scaledTriggerHeight( context, compact: widget.compact, + size: size, ); final chevronGap = context.scaled(QueryaDropdownTokens.triggerChevronGap); final chevronSize = context.scaled(QueryaDropdownTokens.triggerChevronSize); @@ -220,6 +252,7 @@ class _QueryaDropdownState extends material.State> { padding: QueryaDropdownTokens.scaledTriggerPadding( context, compact: widget.compact, + size: size, ), decoration: material.BoxDecoration( color: _triggerHovered @@ -239,6 +272,7 @@ class _QueryaDropdownState extends material.State> { label: label, cs: cs, expand: widget.expandToParent || fieldWidth != null, + size: size, ), material.SizedBox(width: chevronGap), material.Icon( @@ -310,7 +344,7 @@ class _QueryaDropdownState extends material.State> { final fieldWidth = widget.expandToParent ? null : (widget.width != null ? context.scaled(widget.width!) : null); - final menuChildren = _menuChildren(cs); + final menuChildren = _menuChildren(cs, _controlSize(context)); final scaledMaxHeight = context.scaled(widget.menuMaxHeight); final effectiveMaxHeight = widget.items.length > QueryaDropdownTokens.menuScrollItemThreshold @@ -460,6 +494,7 @@ class _QueryaDropdownMenuItem extends material.StatefulWidget { required this.enabled, required this.colorScheme, required this.compact, + required this.size, required this.onPick, }); @@ -468,6 +503,7 @@ class _QueryaDropdownMenuItem extends material.StatefulWidget { final bool enabled; final ColorScheme colorScheme; final bool compact; + final QueryaControlSize size; final material.VoidCallback onPick; @override @@ -507,6 +543,7 @@ class _QueryaDropdownMenuItemState final itemHeight = QueryaDropdownTokens.scaledMenuItemHeight( context, compact: widget.compact, + size: widget.size, ); final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); final slot = context.scaled(QueryaDropdownTokens.selectedCheckSlotWidth); @@ -564,6 +601,7 @@ class _QueryaDropdownMenuItemState cs.popoverForeground, selected: widget.selected, compact: widget.compact, + size: widget.size, ), ), ), diff --git a/lib/shared/widgets/querya_dropdown_tokens.dart b/lib/shared/widgets/querya_dropdown_tokens.dart index a40b88cf..189f53d7 100644 --- a/lib/shared/widgets/querya_dropdown_tokens.dart +++ b/lib/shared/widgets/querya_dropdown_tokens.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; /// Fixed metrics for [QueryaDropdown] — single source of truth for dropdown UI. abstract final class QueryaDropdownTokens { /// Compact desktop trigger height (content is vertically centered). - static const double triggerHeight = 36.0; + static const double triggerHeight = QueryaControlMetrics.heightLg; static const double triggerPaddingHorizontal = 12.0; @@ -18,7 +19,7 @@ abstract final class QueryaDropdownTokens { static const int menuScrollItemThreshold = 8; - static const double menuBorderRadius = 6.0; + static const double menuBorderRadius = QueryaControlMetrics.radius; /// Soft popover shadow (shadcn-like): thin border + low elevation. static const double menuElevation = 2.0; @@ -28,21 +29,21 @@ abstract final class QueryaDropdownTokens { static const material.EdgeInsets menuPadding = material.EdgeInsets.symmetric(vertical: 4.0, horizontal: 4.0); - static const double menuItemHeight = 32.0; + static const double menuItemHeight = QueryaControlMetrics.heightMd; static const material.EdgeInsets menuItemPadding = material.EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0); /// Compact variant for toolbars / panel headers. - static const double compactTriggerHeight = 26.0; + static const double compactTriggerHeight = QueryaControlMetrics.heightSm; static const double compactTriggerPaddingHorizontal = 8.0; - static const double compactFontSize = 12.0; + static const double compactFontSize = QueryaControlMetrics.fontSm; - static const double compactMenuItemHeight = 26.0; + static const double compactMenuItemHeight = QueryaControlMetrics.heightSm; - static const double fontSize = 14.0; + static const double fontSize = QueryaControlMetrics.fontLg; static const double lineHeight = 1.25; @@ -50,27 +51,44 @@ abstract final class QueryaDropdownTokens { static const double selectedCheckSlotWidth = 18.0; + /// The control size a dropdown draws at: an explicit [size], else + /// [QueryaControlSize.sm] for [compact], else [QueryaControlSize.lg]. + static QueryaControlSize sizeFor({ + bool compact = false, + QueryaControlSize? size, + }) => + size ?? (compact ? QueryaControlSize.sm : QueryaControlSize.lg); + static double scaledTriggerHeight(material.BuildContext context, - {bool compact = false}) => - context.scaled(compact ? compactTriggerHeight : triggerHeight); + {bool compact = false, QueryaControlSize? size}) => + context.scaled(sizeFor(compact: compact, size: size).height); static double scaledFontSize(material.BuildContext context, - {bool compact = false}) => - context.scaled(compact ? compactFontSize : fontSize); + {bool compact = false, QueryaControlSize? size}) => + context.scaled(sizeFor(compact: compact, size: size).fontSize); + /// Menu rows follow the trigger down to [QueryaControlSize.sm] but never + /// grow past [QueryaControlSize.md]. static double scaledMenuItemHeight(material.BuildContext context, - {bool compact = false}) => - context.scaled(compact ? compactMenuItemHeight : menuItemHeight); + {bool compact = false, QueryaControlSize? size}) => + context.scaled( + sizeFor(compact: compact, size: size) == QueryaControlSize.sm + ? compactMenuItemHeight + : menuItemHeight, + ); static double scaledMenuMaxHeight(material.BuildContext context) => context.scaled(menuMaxHeight); static material.EdgeInsets scaledTriggerPadding( material.BuildContext context, - {bool compact = false}) => + {bool compact = false, + QueryaControlSize? size}) => material.EdgeInsets.symmetric( horizontal: context.scaled( - compact ? compactTriggerPaddingHorizontal : triggerPaddingHorizontal, + sizeFor(compact: compact, size: size) == QueryaControlSize.sm + ? compactTriggerPaddingHorizontal + : triggerPaddingHorizontal, ), ); @@ -78,10 +96,11 @@ abstract final class QueryaDropdownTokens { material.BuildContext context, material.Color color, { bool compact = false, + QueryaControlSize? size, }) { - final size = scaledFontSize(context, compact: compact); + final fontSize = scaledFontSize(context, compact: compact, size: size); return material.TextStyle( - fontSize: size, + fontSize: fontSize, height: lineHeight, fontWeight: material.FontWeight.w500, color: color, @@ -93,10 +112,11 @@ abstract final class QueryaDropdownTokens { material.Color color, { required bool selected, bool compact = false, + QueryaControlSize? size, }) { - final size = scaledFontSize(context, compact: compact); + final fontSize = scaledFontSize(context, compact: compact, size: size); return material.TextStyle( - fontSize: size, + fontSize: fontSize, height: lineHeight, fontWeight: selected ? material.FontWeight.w600 : material.FontWeight.w400, diff --git a/lib/shared/widgets/querya_icon_button.dart b/lib/shared/widgets/querya_icon_button.dart index 35478e58..50f7f5e6 100644 --- a/lib/shared/widgets/querya_icon_button.dart +++ b/lib/shared/widgets/querya_icon_button.dart @@ -1,20 +1,24 @@ import 'package:flutter/material.dart' as material; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; import 'package:querya_desktop/core/ui/querya_tooltip.dart'; -/// Density sizing presets for Querya icon and toolbar buttons. +/// Legacy names of the control sizes: [dense] is [QueryaControlSize.sm] and +/// [standard] is [QueryaControlSize.md]. New code passes a [QueryaControlSize]. enum QueryaIconButtonDensity { - /// 28x28 box with 15px icon — ideal for dense trees, tab bars, and data grid calc bars. - dense(28, 15), + /// [QueryaControlSize.sm]: dense trees, tab bars, data grid calc bars. + dense(QueryaControlSize.sm), - /// 32x32 box with 18px icon — standard for top toolbars and header chrome. - standard(32, 18); + /// [QueryaControlSize.md]: top toolbars and header chrome. + standard(QueryaControlSize.md); - const QueryaIconButtonDensity(this.boxSize, this.iconSize); + const QueryaIconButtonDensity(this.controlSize); - final double boxSize; - final double iconSize; + final QueryaControlSize controlSize; + + double get boxSize => controlSize.height; + double get iconSize => controlSize.iconSize; } /// Standard icon button with density presets, hover/active states, and built-in tooltip. @@ -24,7 +28,8 @@ class QueryaIconButton extends material.StatefulWidget { required this.icon, this.tooltip, this.onPressed, - this.density = QueryaIconButtonDensity.standard, + this.density, + this.size, this.isActive = false, this.isDestructive = false, this.color, @@ -41,8 +46,12 @@ class QueryaIconButton extends material.StatefulWidget { /// Callback when pressed. If null, the button is disabled. final material.VoidCallback? onPressed; - /// Sizing density: dense (28px) or standard (32px). - final QueryaIconButtonDensity density; + /// Legacy size name; [size] wins when both are set. + final QueryaIconButtonDensity? density; + + /// Control size. Without one the button takes the nearest + /// [QueryaControlScope], then [QueryaControlSize.md]. + final QueryaControlSize? size; /// Whether the button is currently in an active/toggled state. final bool isActive; @@ -72,14 +81,19 @@ class _QueryaIconButtonState extends material.State { final icon = widget.icon; final tooltip = widget.tooltip; final onPressed = widget.onPressed; - final density = widget.density; + final controlSize = widget.size ?? + widget.density?.controlSize ?? + QueryaControlSize.of(context); + final boxSize = controlSize.scaledHeight(context); + final iconSize = controlSize.scaledIconSize(context); final isActive = widget.isActive; final isDestructive = widget.isDestructive; final color = widget.color; final activeColor = widget.activeColor; final cs = Theme.of(context).colorScheme; final workbench = QueryaThemeScope.maybeOf(context)?.workbench; - final radius = widget.borderRadius ?? material.BorderRadius.circular(6); + final radius = widget.borderRadius ?? + material.BorderRadius.circular(controlSize.scaledRadius(context)); final isEnabled = onPressed != null; @@ -136,12 +150,12 @@ class _QueryaIconButtonState extends material.State { splashColor: (isDestructive ? destructiveColor : accentColor) .withValues(alpha: 0.2), child: material.SizedBox( - width: density.boxSize, - height: density.boxSize, + width: boxSize, + height: boxSize, child: material.Center( child: material.IconTheme( data: material.IconThemeData( - size: density.iconSize, + size: iconSize, color: isEnabled ? effectiveIconColor : mutedColor.withValues(alpha: 0.4), @@ -181,7 +195,8 @@ class QueryaToolbarButton extends material.StatefulWidget { this.tooltip, this.shortcutHint, this.onPressed, - this.density = QueryaIconButtonDensity.standard, + this.density, + this.size, this.isActive = false, this.isDestructive = false, }); @@ -191,7 +206,12 @@ class QueryaToolbarButton extends material.StatefulWidget { final String? tooltip; final String? shortcutHint; final material.VoidCallback? onPressed; - final QueryaIconButtonDensity density; + + /// Legacy size name; [size] wins when both are set. + final QueryaIconButtonDensity? density; + + /// Control size; see [QueryaIconButton.size]. + final QueryaControlSize? size; final bool isActive; final bool isDestructive; @@ -211,7 +231,9 @@ class _QueryaToolbarButtonState extends material.State { final tooltip = widget.tooltip; final shortcutHint = widget.shortcutHint; final onPressed = widget.onPressed; - final density = widget.density; + final controlSize = widget.size ?? + widget.density?.controlSize ?? + QueryaControlSize.of(context); final isActive = widget.isActive; final isDestructive = widget.isDestructive; final cs = Theme.of(context).colorScheme; @@ -220,7 +242,8 @@ class _QueryaToolbarButtonState extends material.State { final destructiveColor = workbench?.destructive ?? cs.destructive; final accentColor = workbench?.accent ?? cs.primary; - final radius = material.BorderRadius.circular(6); + final radius = + material.BorderRadius.circular(controlSize.scaledRadius(context)); final effectiveFgColor = isActive ? (isDestructive ? destructiveColor : accentColor) @@ -245,17 +268,17 @@ class _QueryaToolbarButtonState extends material.State { if (icon != null) ...[ material.IconTheme( data: material.IconThemeData( - size: density.iconSize, + size: controlSize.scaledIconSize(context), color: effectiveFgColor, ), child: icon, ), - const Gap(6), + Gap(controlSize.scaledIconGap(context)), ], material.Text( label, style: material.TextStyle( - fontSize: density == QueryaIconButtonDensity.dense ? 11 : 12, + fontSize: controlSize.scaledFontSize(context), fontWeight: material.FontWeight.w500, color: effectiveFgColor, ), @@ -305,9 +328,9 @@ class _QueryaToolbarButtonState extends material.State { hoverColor: hoverColor, focusColor: material.Colors.transparent, child: material.Container( - height: density.boxSize, + height: controlSize.scaledHeight(context), padding: material.EdgeInsets.symmetric( - horizontal: density == QueryaIconButtonDensity.dense ? 8 : 10, + horizontal: controlSize.scaledHorizontalPadding(context), ), alignment: material.Alignment.center, child: content, diff --git a/lib/shared/widgets/querya_search_field.dart b/lib/shared/widgets/querya_search_field.dart index 18cf5293..ad9e354c 100644 --- a/lib/shared/widgets/querya_search_field.dart +++ b/lib/shared/widgets/querya_search_field.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; /// Standard search and filter input field with debounce, clear button, and shortcut support. class QueryaSearchField extends material.StatefulWidget { @@ -16,7 +17,8 @@ class QueryaSearchField extends material.StatefulWidget { this.onSubmitted, this.debounceDuration = const Duration(milliseconds: 250), this.autofocus = false, - this.height = 32, + this.height, + this.size, this.shortcutHint, this.width, }); @@ -42,8 +44,12 @@ class QueryaSearchField extends material.StatefulWidget { /// Whether to autofocus this search field. final bool autofocus; - /// Sizing height. Default is 32px for compact toolbar fit. - final double height; + /// Explicit height in px; wins over [size]. Prefer [size]. + final double? height; + + /// Control size (height, font, icon, radius). Without one the field takes the + /// nearest [QueryaControlScope], then [QueryaControlSize.md] (32 px). + final QueryaControlSize? size; /// Optional keybinding hint displayed at the trailing end (e.g. 'ESC', 'Ctrl+F'). final String? shortcutHint; @@ -132,16 +138,20 @@ class _QueryaSearchFieldState extends material.State { material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; final workbench = QueryaThemeScope.maybeOf(context)?.workbench; + final controlSize = widget.size ?? QueryaControlSize.of(context); + final fieldHeight = widget.height ?? controlSize.scaledHeight(context); + final fieldFont = controlSize.scaledFontSize(context); final searchIconColor = _hasText ? (workbench?.accent ?? cs.primary) : (workbench?.mutedForeground ?? cs.mutedForeground); material.Widget content = material.Container( - height: widget.height, + height: fieldHeight, decoration: material.BoxDecoration( color: cs.card, - borderRadius: material.BorderRadius.circular(6), + borderRadius: + material.BorderRadius.circular(controlSize.scaledRadius(context)), border: material.Border.all( color: _hasText ? (workbench?.accent ?? cs.primary).withValues(alpha: 0.5) @@ -156,7 +166,7 @@ class _QueryaSearchFieldState extends material.State { padding: const material.EdgeInsets.only(left: 8, right: 6), child: material.Icon( material.Icons.search_rounded, - size: 16, + size: controlSize.scaledIconSize(context), color: searchIconColor, ), ), @@ -178,7 +188,7 @@ class _QueryaSearchFieldState extends material.State { onChanged: _onInputChanged, onSubmitted: widget.onSubmitted, style: material.TextStyle( - fontSize: 12, + fontSize: fieldFont, color: cs.foreground, ), cursorHeight: 14, @@ -186,7 +196,7 @@ class _QueryaSearchFieldState extends material.State { decoration: material.InputDecoration( hintText: widget.placeholder, hintStyle: material.TextStyle( - fontSize: 12, + fontSize: fieldFont, color: workbench?.mutedForeground ?? cs.mutedForeground, ), isDense: true, diff --git a/lib/shared/widgets/querya_tab_strip.dart b/lib/shared/widgets/querya_tab_strip.dart index 351887e3..483f5eb7 100644 --- a/lib/shared/widgets/querya_tab_strip.dart +++ b/lib/shared/widgets/querya_tab_strip.dart @@ -6,6 +6,7 @@ import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/core/motion/querya_spring.dart'; import 'package:querya_desktop/core/motion/querya_spring_controller.dart'; +import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; import 'package:querya_desktop/core/ui/querya_tooltip.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -27,6 +28,7 @@ class QueryaTabStrip extends material.StatefulWidget { this.canClose, this.scrollController, this.dense = false, + this.size, }) : assert(labels.length > 0); final List labels; @@ -37,10 +39,15 @@ class QueryaTabStrip extends material.StatefulWidget { final bool Function(int index)? canClose; final material.ScrollController? scrollController; - /// Tighter vertical padding for toolbars: the strip is as tall as a dense - /// icon button instead of a full tab row. + /// Tighter strip for toolbars: the same as [QueryaControlSize.sm], as tall + /// as a dense icon button instead of a full tab row. final bool dense; + /// Control size (tab height). Without one: [QueryaControlSize.sm] when + /// [dense], else the nearest [QueryaControlScope], else + /// [QueryaControlSize.lg]. + final QueryaControlSize? size; + @override material.State createState() => QueryaTabStripState(); } @@ -248,6 +255,11 @@ class QueryaTabStripState extends material.State @override material.Widget build(material.BuildContext context) { final colors = Theme.of(context).colorScheme; + final controlSize = widget.size ?? + (widget.dense + ? QueryaControlSize.sm + : (QueryaControlScope.maybeOf(context) ?? QueryaControlSize.lg)); + final tabHeight = controlSize.scaledHeight(context); _scheduleIndicatorSync(); final tabs = material.Row( @@ -282,23 +294,20 @@ class QueryaTabStripState extends material.State key: material.ValueKey('querya_tab_$label'), duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.enter), - // The close target is 24 px (#1372); the padding - // around it shrinks so the tab keeps its height - // and width. + // The tab is as tall as its control size; the row + // centres its content. The close target is 24 px + // (#1372), so its side padding is smaller. + constraints: + material.BoxConstraints(minHeight: tabHeight), padding: showClose - ? material.EdgeInsets.fromLTRB( - 10, - widget.dense ? 2 : 6, - 5, - widget.dense ? 2 : 6, - ) - : material.EdgeInsets.symmetric( + ? const material.EdgeInsets.fromLTRB(10, 0, 5, 0) + : const material.EdgeInsets.symmetric( horizontal: 12, - vertical: widget.dense ? 4 : 8, ), decoration: material.BoxDecoration( color: material.Colors.transparent, - borderRadius: material.BorderRadius.circular(6), + borderRadius: + material.BorderRadius.circular(QueryaControlMetrics.radius), border: material.Border.all( color: focused ? colors.ring @@ -377,7 +386,8 @@ class QueryaTabStripState extends material.State key: const material.ValueKey('querya_tab_add_button'), padding: const material.EdgeInsets.all(6), decoration: material.BoxDecoration( - borderRadius: material.BorderRadius.circular(6), + borderRadius: + material.BorderRadius.circular(QueryaControlMetrics.radius), ), child: material.Icon( material.Icons.add_rounded, @@ -469,7 +479,8 @@ class _TabStripIndicator extends material.StatelessWidget { child: material.DecoratedBox( decoration: material.BoxDecoration( color: color, - borderRadius: material.BorderRadius.circular(6), + borderRadius: + material.BorderRadius.circular(QueryaControlMetrics.radius), ), ), ), diff --git a/test/core/ui/querya_control_tokens_test.dart b/test/core/ui/querya_control_tokens_test.dart new file mode 100644 index 00000000..ce9ca4f5 --- /dev/null +++ b/test/core/ui/querya_control_tokens_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; +import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; +import 'package:querya_desktop/shared/widgets/querya_icon_button.dart'; + +void main() { + group('QueryaControlSize', () { + test('the three sizes are 28 / 32 / 36 with one radius', () { + expect(QueryaControlSize.sm.height, 28); + expect(QueryaControlSize.md.height, 32); + expect(QueryaControlSize.lg.height, 36); + expect(QueryaControlSize.sm.radius, 6); + expect(QueryaControlSize.lg.radius, 6); + }); + + test('font, icon and padding grow with the size', () { + const sizes = QueryaControlSize.values; + for (var i = 1; i < sizes.length; i++) { + expect(sizes[i].fontSize, greaterThan(sizes[i - 1].fontSize)); + expect(sizes[i].iconSize, greaterThan(sizes[i - 1].iconSize)); + expect( + sizes[i].horizontalPadding, + greaterThan(sizes[i - 1].horizontalPadding), + ); + } + }); + + test('legacy density names map to sm and md', () { + expect(QueryaIconButtonDensity.dense.controlSize, QueryaControlSize.sm); + expect(QueryaIconButtonDensity.standard.controlSize, QueryaControlSize.md); + expect(QueryaIconButtonDensity.dense.boxSize, 28); + expect(QueryaIconButtonDensity.standard.boxSize, 32); + }); + + test('the dropdown tokens are built from the scale', () { + expect(QueryaDropdownTokens.triggerHeight, QueryaControlSize.lg.height); + expect( + QueryaDropdownTokens.compactTriggerHeight, + QueryaControlSize.sm.height, + ); + expect(QueryaDropdownTokens.menuItemHeight, QueryaControlSize.md.height); + }); + }); + + group('QueryaControlScope', () { + Widget probe(void Function(QueryaControlSize) onSize) => Builder( + builder: (context) { + onSize(QueryaControlSize.of(context)); + return const SizedBox.shrink(); + }, + ); + + testWidgets('defaults to md without a scope', (tester) async { + late QueryaControlSize seen; + await tester.pumpWidget(probe((s) => seen = s)); + expect(seen, QueryaControlSize.md); + }); + + testWidgets('the nearest scope wins', (tester) async { + late QueryaControlSize seen; + await tester.pumpWidget( + QueryaControlScope( + size: QueryaControlSize.lg, + child: QueryaControlScope( + size: QueryaControlSize.sm, + child: probe((s) => seen = s), + ), + ), + ); + expect(seen, QueryaControlSize.sm); + }); + + testWidgets('scaled helpers follow the ui scale', (tester) async { + late double height; + await tester.pumpWidget( + QueryaUiScaleScope( + scale: 1.25, + child: Builder( + builder: (context) { + height = QueryaControlSize.md.scaledHeight(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + expect(height, 40); + }); + }); +} diff --git a/test/shared/widgets/querya_control_row_height_test.dart b/test/shared/widgets/querya_control_row_height_test.dart new file mode 100644 index 00000000..7a1fd2b0 --- /dev/null +++ b/test/shared/widgets/querya_control_row_height_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; +import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; +import 'package:querya_desktop/shared/widgets/querya_icon_button.dart'; +import 'package:querya_desktop/shared/widgets/querya_search_field.dart'; +import 'package:querya_desktop/shared/widgets/querya_tab_strip.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +material.Widget _row({QueryaControlSize? size, double uiScale = 1}) { + final controls = material.Row( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + QueryaIconButton( + key: const material.ValueKey('icon'), + icon: const material.Icon(material.Icons.refresh), + tooltip: 'Refresh', + onPressed: () {}, + size: size, + ), + material.SizedBox( + width: 160, + child: QueryaDropdown( + key: const material.ValueKey('dropdown'), + value: 'a', + items: const [ + QueryaDropdownItem(value: 'a', label: 'Alpha'), + QueryaDropdownItem(value: 'b', label: 'Beta'), + ], + onSelected: (_) {}, + size: size, + ), + ), + material.SizedBox( + width: 160, + child: QueryaSearchField( + key: const material.ValueKey('search'), + size: size, + ), + ), + QueryaTabStrip( + key: const material.ValueKey('tabs'), + labels: const ['One', 'Two'], + selectedIndex: 0, + onSelected: (_) {}, + size: size, + ), + ], + ); + return queryaThemeTestShell( + child: QueryaUiScaleScope( + scale: uiScale, + child: material.Center(child: controls), + ), + ); +} + +void main() { + const keys = ['icon', 'dropdown', 'search', 'tabs']; + + Future> heights(WidgetTester tester) async { + await tester.pump(); + await tester.pumpAndSettle(); + return [ + for (final k in keys) + tester.getSize(find.byKey(material.ValueKey(k))).height, + ]; + } + + group('one control size gives one row height (#1333)', () { + for (final size in QueryaControlSize.values) { + for (final scale in [1.0, 1.25]) { + testWidgets('${size.name} at ui scale $scale', (tester) async { + await tester.pumpWidget(_row(size: size, uiScale: scale)); + + final expected = size.height * scale; + expect(await heights(tester), everyElement(closeTo(expected, 0.01))); + }); + } + } + + testWidgets('a control scope sets the size for the whole row', + (tester) async { + // The scope is the only source here: the controls get no size. + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Center( + child: QueryaControlScope( + size: QueryaControlSize.sm, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + QueryaIconButton( + key: const material.ValueKey('icon'), + icon: const material.Icon(material.Icons.refresh), + onPressed: () {}, + ), + const material.SizedBox( + width: 160, + child: QueryaSearchField( + key: material.ValueKey('search'), + ), + ), + QueryaTabStrip( + key: const material.ValueKey('tabs'), + labels: const ['One'], + selectedIndex: 0, + onSelected: (_) {}, + ), + ], + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pumpAndSettle(); + + for (final k in ['icon', 'search', 'tabs']) { + expect( + tester.getSize(find.byKey(material.ValueKey(k))).height, + 28, + reason: k, + ); + } + }); + }); +}