panel splitters get keyboard parity and Semantics (T-111)

Drag handles for sidebar / context / editor split were pure
pointer-Listeners — no Tab focus, no arrow-key adjust, no Semantics.
Each now wraps in a FocusableActionDetector with arrow shortcuts (10
px fine / 50 px coarse for the column handles, 2% / 10% for the
editor split) and a slider Semantics node that announces the current
size. The CLI verb half is split out as T-119 and waits on T-99.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-18 09:43:56 +02:00
co-authored by Claude
parent f113cb5efd
commit 5d1237501c
6 changed files with 207 additions and 40 deletions
+95 -21
View File
@@ -1,11 +1,16 @@
import 'package:clide/kernel/src/panels/arrangement.dart';
import 'package:clide/kernel/src/panels/slot_id.dart';
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
/// A 4-px draggable splitter that adjusts the size of [slot] in the
/// given [arrangement]. Slot hosts wrap this around their edges to make
/// the three-column layout resizable.
/// A 4-px splitter that adjusts the size of [slot] in [arrangement].
/// Slot hosts wrap this around their edges to make the three-column
/// layout resizable.
///
/// Drag with the mouse, or tab to it and use the arrow keys (Shift =
/// coarse step). Exposes a `slider` Semantics node so screen readers
/// announce the current width.
class DragResizeHandle extends StatefulWidget {
const DragResizeHandle({
super.key,
@@ -21,6 +26,8 @@ class DragResizeHandle extends StatefulWidget {
final double thickness;
static const defaultThickness = 8.0;
static const double stepFine = 10.0;
static const double stepCoarse = 50.0;
@override
State<DragResizeHandle> createState() => _DragResizeHandleState();
@@ -28,32 +35,57 @@ class DragResizeHandle extends StatefulWidget {
class _DragResizeHandleState extends State<DragResizeHandle> {
bool _hovered = false;
bool _focused = false;
double? _dragStartSize;
Offset? _dragStartPointer;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final lineColor = _hovered ? tokens.panelActiveBorder : tokens.dividerColor;
final lineColor = (_hovered || _focused) ? tokens.panelActiveBorder : tokens.dividerColor;
return MouseRegion(
cursor: widget.axis == Axis.horizontal ? SystemMouseCursors.resizeColumn : SystemMouseCursors.resizeRow,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Listener(
onPointerDown: _onDown,
onPointerMove: _onMove,
onPointerUp: _onUp,
child: Container(
width: widget.axis == Axis.horizontal ? widget.thickness : null,
height: widget.axis == Axis.vertical ? widget.thickness : null,
color: tokens.chromeBackground,
child: Align(
alignment: widget.slot == Slots.sidebar ? Alignment.centerRight : Alignment.centerLeft,
final size = widget.arrangement.sizeOf(widget.slot);
return Semantics(
container: true,
slider: true,
label: _semanticLabel(),
value: size == null ? null : '${size.round()} pixels',
increasedValue: size == null ? null : '${(size + DragResizeHandle.stepFine).round()} pixels',
decreasedValue: size == null ? null : '${(size - DragResizeHandle.stepFine).round()} pixels',
onIncrease: () => _bump(DragResizeHandle.stepFine),
onDecrease: () => _bump(-DragResizeHandle.stepFine),
child: FocusableActionDetector(
onShowFocusHighlight: (v) => setState(() => _focused = v),
shortcuts: _shortcuts(),
actions: <Type, Action<Intent>>{
_BumpIntent: CallbackAction<_BumpIntent>(
onInvoke: (intent) {
_bump(intent.delta);
return null;
},
),
},
child: MouseRegion(
cursor: widget.axis == Axis.horizontal ? SystemMouseCursors.resizeColumn : SystemMouseCursors.resizeRow,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Listener(
onPointerDown: _onDown,
onPointerMove: _onMove,
onPointerUp: _onUp,
child: Container(
width: widget.axis == Axis.horizontal ? 1 : null,
height: widget.axis == Axis.vertical ? 1 : null,
color: lineColor,
width: widget.axis == Axis.horizontal ? widget.thickness : null,
height: widget.axis == Axis.vertical ? widget.thickness : null,
color: tokens.chromeBackground,
child: Align(
alignment: widget.slot == Slots.sidebar ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
width: widget.axis == Axis.horizontal ? (_focused ? 2 : 1) : null,
height: widget.axis == Axis.vertical ? (_focused ? 2 : 1) : null,
color: lineColor,
),
),
),
),
),
@@ -61,6 +93,43 @@ class _DragResizeHandleState extends State<DragResizeHandle> {
);
}
String _semanticLabel() {
final axis = widget.axis == Axis.horizontal ? 'width' : 'height';
if (widget.slot == Slots.sidebar) return 'Sidebar $axis';
if (widget.slot == Slots.contextPanel) return 'Context panel $axis';
return '${widget.slot.value} $axis';
}
Map<ShortcutActivator, Intent> _shortcuts() {
final horiz = widget.axis == Axis.horizontal;
final fine = DragResizeHandle.stepFine;
final coarse = DragResizeHandle.stepCoarse;
return <ShortcutActivator, Intent>{
if (horiz) ...{
const SingleActivator(LogicalKeyboardKey.arrowLeft): _BumpIntent(-fine),
const SingleActivator(LogicalKeyboardKey.arrowRight): _BumpIntent(fine),
const SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): _BumpIntent(-coarse),
const SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): _BumpIntent(coarse),
} else ...{
const SingleActivator(LogicalKeyboardKey.arrowUp): _BumpIntent(-fine),
const SingleActivator(LogicalKeyboardKey.arrowDown): _BumpIntent(fine),
const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): _BumpIntent(-coarse),
const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): _BumpIntent(coarse),
},
};
}
/// Apply a raw delta in the natural axis direction. The drag handler
/// inverts the sign for context-panel because its handle sits on the
/// left edge of the right-anchored slot; arrow keys follow the same
/// convention so right-arrow always moves the boundary rightward.
void _bump(double rawDelta) {
final current = widget.arrangement.sizeOf(widget.slot);
if (current == null) return;
final delta = widget.slot == Slots.contextPanel ? -rawDelta : rawDelta;
widget.arrangement.setSize(widget.slot, current + delta);
}
void _onDown(PointerDownEvent e) {
_dragStartSize = widget.arrangement.sizeOf(widget.slot);
_dragStartPointer = e.position;
@@ -80,3 +149,8 @@ class _DragResizeHandleState extends State<DragResizeHandle> {
_dragStartPointer = null;
}
}
class _BumpIntent extends Intent {
const _BumpIntent(this.delta);
final double delta;
}