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:
@@ -1808,3 +1808,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-114', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 20:13:36', '2026-05-17 20:13:36', '2026-05-17 20:13:36', NULL, '0bad40fa7f165f828bb0c0757044491e', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-118', 'status', 'backlog', 'done', NULL, '2026-05-17 20:53:41', '2026-05-17 20:53:41', '2026-05-17 20:53:41', NULL, 'ff0126655805fed0ebe45f313c5ceff7', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-114', 'status', 'in_progress', 'done', NULL, '2026-05-18 07:04:38', '2026-05-18 07:04:38', '2026-05-18 07:04:38', NULL, '8cf198f8c824fa8f46277b7e22e68f0c', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-111', 'status', 'in_progress', 'done', NULL, '2026-05-18 07:43:45', '2026-05-18 07:43:45', '2026-05-18 07:43:45', NULL, '792efcf7c772de7cda7069ea3fc3ac0a', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -1837,3 +1837,13 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
|
||||
4. Surface text-zoom (Ctrl +/-/0) in the palette so it''s discoverable.
|
||||
|
||||
Source: consultants.md "UX — Findings — [Minor]" + Strengths section.', 'done', 'low', NULL, NULL, NULL, '2026-05-17 18:48:28', '2026-05-18 07:04:38', NULL, '70c65844ee3e52bfcd6220b77ab8957a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-111', 'story', 'T-97', 'drag-resize keyboard parity + clide panel resize CLI', '`drag_resize.dart` and `app.dart:870-912` are pure `Listener` pointer handlers — no keyboard equivalent, no Semantics node. Per the user/Claude parity rule (D-6), panel sizing should have a CLI affordance. Neither exists.
|
||||
|
||||
**Fix:**
|
||||
1. Add Semantics to drag-resize handles + keyboard activation (focus → arrow keys adjust width).
|
||||
2. Add `clide panel resize <slot> <delta-or-absolute>` CLI verb.
|
||||
|
||||
Coordinate with T-99 (IPC implementation) if the CLI verb needs new dispatch wiring.
|
||||
|
||||
Source: consultants.md "UX — Findings — [Major] Drag-resize handles".', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 18:48:14', '2026-05-18 07:43:45', NULL, '594d1cbcc8daa115246e2398d9ba9506', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-119', 'task', 'T-99', 'clide panel resize CLI verb (split from T-111)', 'Once T-99 lands an IPC dispatch path, register a ''panel.resize'' command that takes <slot> and <delta-or-absolute> args and calls LayoutArrangement.setSize / setEditorRatio. Keyboard parity already landed in T-111; this completes user/Claude parity per D-6.', 'backlog', 'low', NULL, NULL, NULL, '2026-05-18 07:43:50', '2026-05-18 07:43:50', NULL, '29df32cde1a30168a27b763f5e6ce81d', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
@@ -117,6 +117,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
softer ink-tinted shadow (T-114).
|
||||
- Text-zoom (Ctrl +/-/0) is now a kernel `TextZoom` service and shows
|
||||
up in the palette as `View: Zoom In/Out/Reset Zoom` (T-114).
|
||||
- Panel splitters (sidebar / context / editor-split) are tab-focusable;
|
||||
arrow keys nudge by 10 px, Shift+arrow by 50 px (2% / 10% for the
|
||||
editor split). Exposed as slider Semantics nodes so screen readers
|
||||
announce the current size. CLI verb deferred to T-99 (T-111).
|
||||
- Changelog gate is binary — dropped the soft 40-word warning, kept
|
||||
the 60-word hard cap. Warnings that never blocked just normalised
|
||||
drift.
|
||||
|
||||
+62
-19
@@ -967,36 +967,79 @@ class _EditorDragHandle extends StatefulWidget {
|
||||
|
||||
class _EditorDragHandleState extends State<_EditorDragHandle> {
|
||||
bool _hovered = false;
|
||||
bool _focused = false;
|
||||
double? _dragStartRatio;
|
||||
double? _dragStartY;
|
||||
|
||||
// Editor split is a 0..1 fraction; the kernel clamps to 0.15..0.70.
|
||||
// 2% per fine step, 10% per Shift step keeps keyboard feel close to
|
||||
// the pixel-based DragResizeHandle.
|
||||
static const double _stepFine = 0.02;
|
||||
static const double _stepCoarse = 0.10;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeRow,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Listener(
|
||||
onPointerDown: (e) {
|
||||
_dragStartRatio = widget.arrangement.editorRatio;
|
||||
_dragStartY = e.position.dy;
|
||||
final lineColor = (_hovered || _focused) ? tokens.panelActiveBorder : tokens.panelBorder;
|
||||
|
||||
return Semantics(
|
||||
container: true,
|
||||
slider: true,
|
||||
label: 'Editor split',
|
||||
value: '${(widget.arrangement.editorRatio * 100).round()}%',
|
||||
onIncrease: () => _bump(_stepFine),
|
||||
onDecrease: () => _bump(-_stepFine),
|
||||
child: FocusableActionDetector(
|
||||
onShowFocusHighlight: (v) => setState(() => _focused = v),
|
||||
shortcuts: const <ShortcutActivator, Intent>{
|
||||
SingleActivator(LogicalKeyboardKey.arrowUp): _EditorBumpIntent(-_stepFine),
|
||||
SingleActivator(LogicalKeyboardKey.arrowDown): _EditorBumpIntent(_stepFine),
|
||||
SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): _EditorBumpIntent(-_stepCoarse),
|
||||
SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): _EditorBumpIntent(_stepCoarse),
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
final startR = _dragStartRatio;
|
||||
final startY = _dragStartY;
|
||||
if (startR == null || startY == null || widget.totalHeight <= 0) return;
|
||||
final deltaRatio = (e.position.dy - startY) / widget.totalHeight;
|
||||
widget.arrangement.setEditorRatio(startR + deltaRatio);
|
||||
actions: <Type, Action<Intent>>{
|
||||
_EditorBumpIntent: CallbackAction<_EditorBumpIntent>(
|
||||
onInvoke: (intent) {
|
||||
_bump(intent.delta);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
onPointerUp: (_) {
|
||||
_dragStartRatio = null;
|
||||
_dragStartY = null;
|
||||
},
|
||||
child: Container(height: 4, color: _hovered ? tokens.panelActiveBorder : tokens.panelBorder),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeRow,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Listener(
|
||||
onPointerDown: (e) {
|
||||
_dragStartRatio = widget.arrangement.editorRatio;
|
||||
_dragStartY = e.position.dy;
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
final startR = _dragStartRatio;
|
||||
final startY = _dragStartY;
|
||||
if (startR == null || startY == null || widget.totalHeight <= 0) return;
|
||||
final deltaRatio = (e.position.dy - startY) / widget.totalHeight;
|
||||
widget.arrangement.setEditorRatio(startR + deltaRatio);
|
||||
},
|
||||
onPointerUp: (_) {
|
||||
_dragStartRatio = null;
|
||||
_dragStartY = null;
|
||||
},
|
||||
child: Container(height: 4, color: lineColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _bump(double delta) {
|
||||
widget.arrangement.setEditorRatio(widget.arrangement.editorRatio + delta);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditorBumpIntent extends Intent {
|
||||
const _EditorBumpIntent(this.delta);
|
||||
final double delta;
|
||||
}
|
||||
|
||||
class _ContextSlot extends StatelessWidget {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ library;
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/semantics.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -83,6 +84,40 @@ void main() {
|
||||
expect(arr.sizeOf(Slots.contextPanel), 150);
|
||||
});
|
||||
|
||||
testWidgets('exposes a slider Semantics node with the current size', (tester) async {
|
||||
final arr = LayoutArrangement();
|
||||
arr.applyPreset(const LayoutPresetContribution(
|
||||
id: 'test-preset',
|
||||
displayName: 'Test',
|
||||
slots: [
|
||||
LayoutSlot(slot: Slots.sidebar, position: SlotPosition.left, defaultSize: 240),
|
||||
],
|
||||
));
|
||||
final semHandle = tester.ensureSemantics();
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
height: 200,
|
||||
child: DragResizeHandle(
|
||||
arrangement: arr,
|
||||
slot: Slots.sidebar,
|
||||
axis: Axis.horizontal,
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
final data = tester.getSemantics(find.byType(DragResizeHandle));
|
||||
expect(data.label, 'Sidebar width');
|
||||
expect(data.value, '240 pixels');
|
||||
final actions = data.getSemanticsData().actions;
|
||||
expect(actions & SemanticsAction.increase.index, isNot(0));
|
||||
expect(actions & SemanticsAction.decrease.index, isNot(0));
|
||||
semHandle.dispose();
|
||||
});
|
||||
|
||||
testWidgets('hovered state flips the line colour without throwing', (tester) async {
|
||||
final arr = LayoutArrangement();
|
||||
arr.applyPreset(const LayoutPresetContribution(
|
||||
|
||||
Reference in New Issue
Block a user