Menus: one weight, balanced padding, focus shown only for keys, picker text lines up with its button

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-11 15:36:51 +03:00
1 parent 52f1c481a6
commit cf2805d3bd
1 file changed
+47 -12
+47 -12
View File
@@ -2,6 +2,7 @@ import 'dart:math' as math;
import 'dart:ui' show ImageFilter; import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../controls/uosc/controls.dart'; import '../controls/uosc/controls.dart';
import '../shell/motion.dart'; import '../shell/motion.dart';
@@ -125,17 +126,22 @@ class AppMenuItem<T> {
/// Opens the menu at [context]'s box (the button). Resolves to the picked /// Opens the menu at [context]'s box (the button). Resolves to the picked
/// value, or null when dismissed. [minWidth]: at least this wide (a picker /// value, or null when dismissed. [minWidth]: at least this wide (a picker
/// is as wide as its button). /// is as wide as its button).
///
/// [textAt]: where the button's own label starts inside it; the menu then
/// sits so its rows' text lines up with that label (a picker), else it
/// lines up with the button's left edge.
Future<T?> showAppMenu<T>( Future<T?> showAppMenu<T>(
BuildContext context, { BuildContext context, {
required List<AppMenuItem<T>> items, required List<AppMenuItem<T>> items,
double minWidth = 0, double minWidth = 0,
double? textAt,
}) async { }) async {
final i = await _openMenu(context, items, minWidth); final i = await _openMenu(context, items, minWidth, textAt);
return i == null ? null : items[i].value; return i == null ? null : items[i].value;
} }
/// The picked row's index (the value may itself be null). /// The picked row's index (the value may itself be null).
Future<int?> _openMenu(BuildContext context, List<AppMenuItem<Object?>> items, double minWidth) { Future<int?> _openMenu(BuildContext context, List<AppMenuItem<Object?>> items, double minWidth, [double? textAt]) {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
final overlay = navigator.overlay!.context.findRenderObject() as RenderBox; final overlay = navigator.overlay!.context.findRenderObject() as RenderBox;
final box = context.findRenderObject() as RenderBox; final box = context.findRenderObject() as RenderBox;
@@ -143,10 +149,13 @@ Future<int?> _openMenu(BuildContext context, List<AppMenuItem<Object?>> items, d
box.localToGlobal(Offset.zero, ancestor: overlay), box.localToGlobal(Offset.zero, ancestor: overlay),
box.localToGlobal(box.size.bottomRight(Offset.zero), ancestor: overlay), box.localToGlobal(box.size.bottomRight(Offset.zero), ancestor: overlay),
); );
// Lines the rows' text up with the button's label: panel padding (4)
// plus the row's text inset, against [textAt].
final shift = textAt == null ? 0.0 : textAt - 4 - _MenuRow.textInset(items.any((i) => i.checked != null));
Future<int?> open() => navigator.push<int>(_AppMenuRoute( Future<int?> open() => navigator.push<int>(_AppMenuRoute(
anchor: anchor, anchor: anchor.translate(shift, 0),
items: items, items: items,
minWidth: minWidth, minWidth: minWidth - shift,
capturedThemes: InheritedTheme.capture(from: context, to: navigator.context), capturedThemes: InheritedTheme.capture(from: context, to: navigator.context),
)); ));
// Over the uosc controls: keep them up while the menu is open. // Over the uosc controls: keep them up while the menu is open.
@@ -251,6 +260,7 @@ class _AppMenuPanel extends StatefulWidget {
class _AppMenuPanelState extends State<_AppMenuPanel> { class _AppMenuPanelState extends State<_AppMenuPanel> {
final _current = GlobalKey(); final _current = GlobalKey();
bool _keys = false;
// Focus (and scroll to) the current row, else the first enabled one. // Focus (and scroll to) the current row, else the first enabled one.
late final int _start = () { late final int _start = () {
@@ -270,7 +280,7 @@ class _AppMenuPanelState extends State<_AppMenuPanel> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final checks = widget.items.any((i) => i.checked != null); final checks = widget.items.any((i) => i.checked != null);
return AppGlass( final panel = AppGlass(
key: const ValueKey('app-menu'), key: const ValueKey('app-menu'),
child: IntrinsicWidth( child: IntrinsicWidth(
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -285,6 +295,7 @@ class _AppMenuPanelState extends State<_AppMenuPanel> {
item: item, item: item,
checks: checks, checks: checks,
autofocus: i == _start, autofocus: i == _start,
showFocus: _keys,
onTap: () => widget.onPick(i), onTap: () => widget.onPick(i),
), ),
], ],
@@ -292,6 +303,15 @@ class _AppMenuPanelState extends State<_AppMenuPanel> {
), ),
), ),
); );
return Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (_, e) {
if (!_keys && e is KeyDownEvent) setState(() => _keys = true);
return KeyEventResult.ignored;
},
child: panel,
);
} }
} }
@@ -299,8 +319,20 @@ class _MenuRow extends StatelessWidget {
final AppMenuItem<Object?> item; final AppMenuItem<Object?> item;
final bool checks; final bool checks;
final bool autofocus; final bool autofocus;
// Keyboard in use: the focused row shows (else focus stays invisible,
// so the current row doesn't look hovered when the menu opens).
final bool showFocus;
final VoidCallback onTap; final VoidCallback onTap;
const _MenuRow({super.key, required this.item, required this.checks, required this.autofocus, required this.onTap}); const _MenuRow(
{super.key,
required this.item,
required this.checks,
required this.autofocus,
required this.showFocus,
required this.onTap});
/// Where a row's text starts, from the row's left edge.
static double textInset(bool checks) => checks ? 8 + 22 : 12;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -313,17 +345,17 @@ class _MenuRow extends StatelessWidget {
onTap: item.enabled ? onTap : null, onTap: item.enabled ? onTap : null,
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius), borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
hoverColor: AppMenuStyle.hover, hoverColor: AppMenuStyle.hover,
focusColor: AppMenuStyle.focus, focusColor: showFocus ? AppMenuStyle.focus : Colors.transparent,
highlightColor: AppMenuStyle.focus, highlightColor: AppMenuStyle.focus,
splashFactory: NoSplash.splashFactory, splashFactory: NoSplash.splashFactory,
child: Container( child: Container(
height: AppMenuStyle.rowHeight, height: AppMenuStyle.rowHeight,
padding: EdgeInsets.only(left: checks ? 6 : 12, right: 12), padding: EdgeInsets.only(left: checks ? 8 : 12, right: 16),
child: Row( child: Row(
children: [ children: [
if (checks) if (checks)
SizedBox( SizedBox(
width: 26, width: 22,
child: on ? Icon(Icons.check_rounded, size: 16, color: scheme.primary) : null, child: on ? Icon(Icons.check_rounded, size: 16, color: scheme.primary) : null,
), ),
Flexible( Flexible(
@@ -332,7 +364,7 @@ class _MenuRow extends StatelessWidget {
item.label, item.label,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 14, color: color, fontWeight: on ? FontWeight.w600 : FontWeight.normal), style: TextStyle(fontSize: 14, color: color),
), ),
), ),
if (item.hint != null) ...[ if (item.hint != null) ...[
@@ -351,6 +383,9 @@ class _MenuRow extends StatelessWidget {
/// label) with the current one checked. [onChanged] null: disabled. /// label) with the current one checked. [onChanged] null: disabled.
/// [expanded]: fills the width it gets (the label ellipsizes). /// [expanded]: fills the width it gets (the label ellipsizes).
class AppSelect<T> extends StatefulWidget { class AppSelect<T> extends StatefulWidget {
/// Where the label starts inside the button (its left padding); the
/// menu's text lines up with it.
static const labelInset = 10.0;
final T value; final T value;
final List<(T, String)> items; final List<(T, String)> items;
final ValueChanged<T>? onChanged; final ValueChanged<T>? onChanged;
@@ -378,7 +413,7 @@ class _AppSelectState<T> extends State<AppSelect<T>> {
final items = [ final items = [
for (final (v, label) in widget.items) AppMenuItem<T>(value: v, label: label, checked: v == widget.value), for (final (v, label) in widget.items) AppMenuItem<T>(value: v, label: label, checked: v == widget.value),
]; ];
final i = await _openMenu(context, items, box.size.width); final i = await _openMenu(context, items, box.size.width, AppSelect.labelInset);
if (!mounted) return; if (!mounted) return;
setState(() => _open = false); setState(() => _open = false);
if (i != null) widget.onChanged?.call(widget.items[i].$1); if (i != null) widget.onChanged?.call(widget.items[i].$1);
@@ -409,7 +444,7 @@ class _AppSelectState<T> extends State<AppSelect<T>> {
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
curve: ease, curve: ease,
padding: const EdgeInsets.only(left: 10, right: 6, top: 6, bottom: 6), padding: const EdgeInsets.only(left: AppSelect.labelInset, right: 6, top: 6, bottom: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: _open ? 0.10 : 0.05), color: Colors.white.withValues(alpha: _open ? 0.10 : 0.05),
borderRadius: r, borderRadius: r,