feat: add keyboard shortcuts for notes, the note list and the app

This commit is contained in:
randogoth 2026-10-13 11:31:14 +03:00
parent ed2de5e67f
commit 722390ecec
9 changed files with 532 additions and 60 deletions

View file

@ -89,6 +89,22 @@ void main() {
});
});
group('toggleEmphasis', () {
test('wraps, unwraps and inserts empty pairs', () {
final bold = toggleEmphasis(_value('a word', 2, 6), '**');
expect(bold.text, 'a **word**');
expect(bold.selection, const TextSelection(baseOffset: 4, extentOffset: 8));
expect(toggleEmphasis(bold, '**').text, 'a word');
final boldItalic = toggleEmphasis(bold, '*');
expect(boldItalic.text, 'a ***word***');
expect(toggleEmphasis(boldItalic, '*').text, 'a **word**');
expect(toggleEmphasis(boldItalic, '**').text, 'a *word*');
expect(_marked(toggleEmphasis(_caret('x|'), '*')), 'x*|*');
});
});
group('highlightMarkdown', () {
test('line and inline syntax', () {
final classes = _classes(

View file

@ -0,0 +1,210 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:muzzle_velocity/markdown_editing.dart';
import 'package:muzzle_velocity/markdown_view.dart';
import 'package:muzzle_velocity/note_editor.dart';
import 'package:muzzle_velocity/themes/day.dart';
import 'package:muzzle_velocity/themes/night.dart';
import 'package:shared_preferences/shared_preferences.dart';
Future<void> _press(WidgetTester tester, LogicalKeyboardKey key,
{bool control = false, bool shift = false}) async {
if (control) await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
if (shift) await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyEvent(key);
if (shift) await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
if (control) await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pumpAndSettle();
}
void main() {
late File note;
setUp(() {
SharedPreferences.setMockInitialValues({});
note = File('${Directory.systemTemp.createTempSync('mv').path}/n.md')
..writeAsStringSync('one word\ntwo word\n');
});
Future<TextField> pumpEditor(WidgetTester tester,
{Future<void> Function()? onQuit}) async {
await tester.runAsync(() async {
await tester.pumpWidget(MaterialApp(
home: NoteEditorScreen(
note: note,
notesDirectoryPath: note.parent.path,
searchTerms: const ['word'],
onQuit: onQuit ?? () async {},
),
));
// File reads complete outside the fake clock.
await Future<void>.delayed(const Duration(milliseconds: 100));
});
await tester.pumpAndSettle();
return tester.widget<TextField>(find.byType(TextField));
}
TextField field(WidgetTester tester) =>
tester.widget<TextField>(find.byType(TextField));
testWidgets('Ctrl+E toggles editing and Esc leaves it', (tester) async {
expect((await pumpEditor(tester)).readOnly, isTrue);
await _press(tester, LogicalKeyboardKey.keyE, control: true);
expect(field(tester).readOnly, isFalse);
await _press(tester, LogicalKeyboardKey.escape);
expect(field(tester).readOnly, isTrue);
});
testWidgets('Ctrl+B and Ctrl+I wrap the selection in edit mode',
(tester) async {
await pumpEditor(tester);
await _press(tester, LogicalKeyboardKey.keyE, control: true);
final controller = field(tester).controller!;
controller.selection = const TextSelection(baseOffset: 0, extentOffset: 3);
await _press(tester, LogicalKeyboardKey.keyB, control: true);
expect(controller.text, startsWith('**one** word'));
await _press(tester, LogicalKeyboardKey.keyI, control: true);
expect(controller.text, startsWith('***one*** word'));
});
testWidgets('F3 and Shift+F3 cycle search matches', (tester) async {
await pumpEditor(tester);
final controller = field(tester).controller! as MarkdownController;
expect(controller.currentMatch, 0);
await _press(tester, LogicalKeyboardKey.f3);
expect(controller.currentMatch, 1);
await _press(tester, LogicalKeyboardKey.f3);
expect(controller.currentMatch, 0);
await _press(tester, LogicalKeyboardKey.f3, shift: true);
expect(controller.currentMatch, 1);
});
testWidgets('arrows scroll in read and preview mode, not in edit mode',
(tester) async {
note.writeAsStringSync(List.generate(200, (i) => 'line $i').join('\n'));
await pumpEditor(tester);
ScrollController sourceScroll() => field(tester).scrollController!;
await _press(tester, LogicalKeyboardKey.arrowDown);
expect(sourceScroll().offset, greaterThan(0));
await _press(tester, LogicalKeyboardKey.arrowUp);
expect(sourceScroll().offset, 0);
await _press(tester, LogicalKeyboardKey.arrowDown);
final readOffset = sourceScroll().offset;
await _press(tester, LogicalKeyboardKey.keyE, control: true);
expect(sourceScroll().offset, readOffset,
reason: 'entering edit mode keeps the scroll position');
await _press(tester, LogicalKeyboardKey.arrowDown);
expect(sourceScroll().offset, readOffset,
reason: 'arrows move the caret in edit mode');
await _press(tester, LogicalKeyboardKey.keyP, control: true);
final preview = tester
.widget<SingleChildScrollView>(find.byType(SingleChildScrollView))
.controller!;
await _press(tester, LogicalKeyboardKey.arrowDown);
expect(preview.offset, greaterThan(0));
});
testWidgets('Page Down and Page Up scroll by a screen in read mode',
(tester) async {
note.writeAsStringSync(List.generate(400, (i) => 'line $i').join('\n'));
await pumpEditor(tester);
final scroll = field(tester).scrollController!;
await _press(tester, LogicalKeyboardKey.pageDown);
final page = scroll.offset;
expect(page, closeTo(scroll.position.viewportDimension * 0.9, 1));
await _press(tester, LogicalKeyboardKey.pageUp);
expect(scroll.offset, 0);
});
testWidgets('Ctrl+Q quits, asking first about unsaved changes',
(tester) async {
var quits = 0;
await pumpEditor(tester, onQuit: () async => quits++);
await _press(tester, LogicalKeyboardKey.keyQ, control: true);
expect(quits, 1);
field(tester).controller!.text = 'changed';
await _press(tester, LogicalKeyboardKey.keyQ, control: true);
expect(find.text('Save Changes?'), findsOneWidget);
expect(quits, 1);
await tester.tap(find.text('Discard'));
await tester.pumpAndSettle();
expect(quits, 2);
});
testWidgets('Ctrl+L goes back to the list', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(MaterialApp(
home: Builder(
builder: (context) => TextButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => NoteEditorScreen(
note: note, notesDirectoryPath: note.parent.path),
),
),
child: const Text('list'),
),
),
));
await tester.tap(find.text('list'));
await tester.pump();
await Future<void>.delayed(const Duration(milliseconds: 100));
});
await tester.pumpAndSettle();
expect(find.byType(NoteEditorScreen), findsOneWidget);
await _press(tester, LogicalKeyboardKey.keyL, control: true);
expect(find.byType(NoteEditorScreen), findsNothing);
});
testWidgets('the open note follows theme switches', (tester) async {
final mode = ValueNotifier(ThemeMode.light);
await tester.runAsync(() async {
await tester.pumpWidget(ValueListenableBuilder(
valueListenable: mode,
builder: (context, themeMode, _) => MaterialApp(
theme: ThemeData(brightness: Brightness.light),
darkTheme: ThemeData(brightness: Brightness.dark),
themeMode: themeMode,
home: NoteEditorScreen(note: note, notesDirectoryPath: null),
),
));
await Future<void>.delayed(const Duration(milliseconds: 100));
});
await tester.pumpAndSettle();
final controller = field(tester).controller! as MarkdownController;
expect(controller.theme, same(dayTheme));
mode.value = ThemeMode.dark;
await tester.pumpAndSettle();
expect(controller.theme, same(nightTheme));
});
testWidgets('Ctrl+= and Ctrl+- change the font size', (tester) async {
await pumpEditor(tester);
double size() => tester.widget<MarkdownView>(find.byType(MarkdownView)).fontSize;
await _press(tester, LogicalKeyboardKey.equal, control: true);
expect(size(), 18);
await _press(tester, LogicalKeyboardKey.minus, control: true);
await _press(tester, LogicalKeyboardKey.minus, control: true);
expect(size(), 14);
await _press(tester, LogicalKeyboardKey.digit0, control: true);
expect(size(), 16);
});
}