Space never presses a focused button; chat toasts open the chat; plain wheel always scrolls the page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 07:52:48 +03:00
1 parent cf80bb2167
commit f2272d7980
8 files changed
+56 -21

No files matched your search

+1 -1
View File
@@ -64,7 +64,7 @@ when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is
loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or
Tab), ready to type; it shows messages, and its "Activity" toggle adds Tab), ready to type; it shows messages, and its "Activity" toggle adds
joins, ready, seeks and the like between them. Joins, leaves, chat and joins, ready, seeks and the like between them. Joins, leaves, chat and
other news also pop up bottom left with the person's avatar. Unread chat other news also pop up bottom left with the person's avatar; click a message to open the chat. Unread chat
shows as a count on the chat buttons and, in fullscreen with the controls shows as a count on the chat buttons and, in fullscreen with the controls
hidden, as a small chip top right. hidden, as a small chip top right.
@@ -501,7 +501,6 @@ class _SchedulePageState extends State<SchedulePage> {
(byDay[DateTime(l.year, l.month, l.day)] ??= []).add(e); (byDay[DateTime(l.year, l.month, l.day)] ??= []).add(e);
} }
return HScroll( return HScroll(
wheel: false,
controller: _scroll, controller: _scroll,
builder: (c) => ListView.separated( builder: (c) => ListView.separated(
key: const ValueKey('schedule-board'), key: const ValueKey('schedule-board'),
+5 -1
View File
@@ -669,7 +669,11 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
), ),
), ),
if (online && _shown != PlayerMode.none) _playerLayer(area), if (online && _shown != PlayerMode.none) _playerLayer(area),
Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: const PartyToasts()), Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: PartyToasts(
onOpenChat: () {
if (!_chatOpen) _toggleChat();
},
)),
], ],
); );
}); });
+7 -7
View File
@@ -90,17 +90,17 @@ Future<T?> showPopover<T>(
}, },
); );
/// Horizontal lists for mouse users: drag with the mouse, and the plain /// Horizontal lists for mouse users: drag with the mouse or Shift+wheel;
/// (vertical) wheel moves them sideways. At either end the wheel is left /// the plain wheel stays vertical (the page scrolls under the cursor, not
/// to the page, so a row never traps vertical scrolling. /// the row).
class HScroll extends StatefulWidget { class HScroll extends StatefulWidget {
final ScrollController? controller; final ScrollController? controller;
final Widget Function(ScrollController controller) builder; final Widget Function(ScrollController controller) builder;
/// Map the plain (vertical) wheel to sideways scrolling. Off where the /// Also map the plain (vertical) wheel to sideways scrolling (at either
/// children scroll vertically themselves (the schedule's day columns): /// end the wheel goes to the page). Off by default: one wheel doing two
/// there only mouse drag and Shift+wheel move sideways. /// things was confusing.
final bool wheel; final bool wheel;
const HScroll({super.key, this.controller, required this.builder, this.wheel = true}); const HScroll({super.key, this.controller, required this.builder, this.wheel = false});
@override @override
State<HScroll> createState() => _HScrollState(); State<HScroll> createState() => _HScrollState();
@@ -92,6 +92,7 @@ class PartyStrip extends StatelessWidget {
message: 'Your profile', message: 'Your profile',
child: InkWell( child: InkWell(
key: const ValueKey('strip-profile'), key: const ValueKey('strip-profile'),
canRequestFocus: false,
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
onTap: onProfile, onTap: onProfile,
child: StatusAvatar( child: StatusAvatar(
+32 -5
View File
@@ -43,10 +43,13 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
} }
/// The coordinator's toasts as cards with avatars, bottom-left: each /// The coordinator's toasts as cards with avatars, bottom-left: each
/// slides in, stays [life], then slides out and collapses its gap. /// slides in, stays [life], then slides out and collapses its gap. A click
/// on a chat toast opens the chat ([onOpenChat]); the rest let clicks
/// through.
class PartyToasts extends StatefulWidget { class PartyToasts extends StatefulWidget {
final Duration life; final Duration life;
const PartyToasts({super.key, this.life = const Duration(seconds: 5)}); final VoidCallback? onOpenChat;
const PartyToasts({super.key, this.life = const Duration(seconds: 5), this.onOpenChat});
@override @override
State<PartyToasts> createState() => _PartyToastsState(); State<PartyToasts> createState() => _PartyToastsState();
@@ -101,13 +104,37 @@ class _PartyToastsState extends State<PartyToasts> {
super.dispose(); super.dispose();
} }
void _open(ShellToast t) {
widget.onOpenChat?.call();
setState(() => t.leaving = true);
}
@override @override
Widget build(BuildContext context) => IgnorePointer(child: ToastColumn(toasts: _shown.reversed.take(6).toList().reversed.toList())); Widget build(BuildContext context) => ToastColumn(
toasts: _shown.reversed.take(6).toList().reversed.toList(),
onTap: widget.onOpenChat == null ? null : _open,
);
} }
class ToastColumn extends StatelessWidget { class ToastColumn extends StatelessWidget {
final List<ShellToast> toasts; final List<ShellToast> toasts;
const ToastColumn({super.key, required this.toasts}); /// Clicks on chat toasts; null (or any other toast): clicks pass through.
final ValueChanged<ShellToast>? onTap;
const ToastColumn({super.key, required this.toasts, this.onTap});
Widget _card(ShellToast t) {
final card = ToastCard(toast: t);
final tap = onTap;
if (!t.chat || tap == null || t.leaving) return IgnorePointer(child: card);
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
key: ValueKey('toast-open-chat-${t.id}'),
onTap: () => tap(t),
child: card,
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -139,7 +166,7 @@ class ToastColumn extends StatelessWidget {
duration: easeTime, duration: easeTime,
curve: ease, curve: ease,
opacity: t.leaving ? 0 : 1, opacity: t.leaving ? 0 : 1,
child: Padding(padding: const EdgeInsets.only(top: 8), child: ToastCard(toast: t)), child: Padding(padding: const EdgeInsets.only(top: 8), child: _card(t)),
), ),
), ),
), ),
+5 -3
View File
@@ -4,13 +4,15 @@ import 'package:flutter/services.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import 'text_focus.dart'; import 'text_focus.dart';
/// Tab and Shift+Tab never move focus (the app's [Shortcuts] override): /// Tab and Shift+Tab never move focus, and Space never presses the
/// a button that got focus that way would be pressed by Space, the ready /// focused button (the app's [Shortcuts] override): Space is the ready key,
/// key. /// and a button keeps focus after a click. Text fields still type spaces
/// (their own shortcuts come first).
Map<ShortcutActivator, Intent> get appShortcuts => { Map<ShortcutActivator, Intent> get appShortcuts => {
...WidgetsApp.defaultShortcuts, ...WidgetsApp.defaultShortcuts,
const SingleActivator(LogicalKeyboardKey.tab): const DoNothingIntent(), const SingleActivator(LogicalKeyboardKey.tab): const DoNothingIntent(),
const SingleActivator(LogicalKeyboardKey.tab, shift: true): const DoNothingIntent(), const SingleActivator(LogicalKeyboardKey.tab, shift: true): const DoNothingIntent(),
const SingleActivator(LogicalKeyboardKey.space): const DoNothingIntent(),
}; };
/// The window's two app-wide keys, ahead of whatever has focus: while in a /// The window's two app-wide keys, ahead of whatever has focus: while in a
+5 -3
View File
@@ -525,8 +525,9 @@ One window, `AppShell` (`lib/src/shell/`), whatever the connection state
through for views that replace each other (the old one gone after 35%, the through for views that replace each other (the old one gone after 35%, the
new one fading and scaling in from 0.985), `showPopover` (cards that fade new one fading and scaling in from 0.985), `showPopover` (cards that fade
and scale in from where they open; they scroll when the window is short) and scale in from where they open; they scroll when the window is short)
and `HScroll` (horizontal rows: mouse drag, the vertical wheel scrolls them and `HScroll` (horizontal rows: mouse drag or Shift+wheel; the plain wheel
sideways until an end, then the page). always scrolls the page). Space never presses a focused button
(`appShortcuts`): it is the ready key.
- **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card; 42 px like the party's), - **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card; 42 px like the party's),
the Library / Schedule / Settings tabs with a growing underline (an the Library / Schedule / Settings tabs with a growing underline (an
@@ -687,7 +688,8 @@ sideways until an end, then the page).
**Toasts** (`toasts.dart`): the coordinator's toasts bottom left, with **Toasts** (`toasts.dart`): the coordinator's toasts bottom left, with
the avatar of their `ToastItem.actor` (feed and chat toasts; the app's the avatar of their `ToastItem.actor` (feed and chat toasts; the app's
own have none); each own have none); each
slides in, stays 5 s, then slides out and collapses its gap. slides in, stays 5 s, then slides out and collapses its gap. A click on
a chat toast opens the chat; the others let clicks through.
Settings persist in two Hive boxes: `connect_form` (last form, recent Settings persist in two Hive boxes: `connect_form` (last form, recent
servers and links, the profile picture and colour; saved even when a join servers and links, the profile picture and colour; saved even when a join