From 5d1237501c7f2cfdc525aa7aae9bbab98cd6ad26 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 18 May 2026 09:43:56 +0200 Subject: [PATCH] panel splitters get keyboard parity and Semantics (T-111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .pql/changelog/ticket_history/2026-05.sql | 1 + .pql/changelog/tickets/2026-05.sql | 10 ++ CHANGELOG.md | 4 + lib/app.dart | 81 ++++++++++--- lib/kernel/src/panels/drag_resize.dart | 116 +++++++++++++++---- test/kernel/src/panels/drag_resize_test.dart | 35 ++++++ 6 files changed, 207 insertions(+), 40 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index a7966dbd..18404f9a 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 5e74e8bb..b23d1adf 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -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 ` 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 and 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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 5402a57d..dc844cf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/lib/app.dart b/lib/app.dart index 56c9238b..114f486b 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -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 { + 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: >{ + _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 { diff --git a/lib/kernel/src/panels/drag_resize.dart b/lib/kernel/src/panels/drag_resize.dart index 75b772b7..b1f578ca 100644 --- a/lib/kernel/src/panels/drag_resize.dart +++ b/lib/kernel/src/panels/drag_resize.dart @@ -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 createState() => _DragResizeHandleState(); @@ -28,32 +35,57 @@ class DragResizeHandle extends StatefulWidget { class _DragResizeHandleState extends State { 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: >{ + _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 { ); } + 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 _shortcuts() { + final horiz = widget.axis == Axis.horizontal; + final fine = DragResizeHandle.stepFine; + final coarse = DragResizeHandle.stepCoarse; + return { + 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 { _dragStartPointer = null; } } + +class _BumpIntent extends Intent { + const _BumpIntent(this.delta); + final double delta; +} diff --git a/test/kernel/src/panels/drag_resize_test.dart b/test/kernel/src/panels/drag_resize_test.dart index 07d837f4..1d6f5f72 100644 --- a/test/kernel/src/panels/drag_resize_test.dart +++ b/test/kernel/src/panels/drag_resize_test.dart @@ -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(