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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user