From 722390ecec31c4d5ceb6ad3e23a15c9e7a3b0d9f Mon Sep 17 00:00:00 2001 From: randogoth Date: Tue, 13 Oct 2026 11:31:14 +0300 Subject: [PATCH] feat: add keyboard shortcuts for notes, the note list and the app --- lib/main.dart | 10 ++ lib/markdown_editing.dart | 39 ++++- lib/markdown_view.dart | 13 ++ lib/note_editor.dart | 144 ++++++++++++++++-- lib/note_list.dart | 138 ++++++++++++------ lib/read_only.dart | 6 +- lib/shortcuts.dart | 16 ++ test/markdown_editing_test.dart | 16 ++ test/note_editor_shortcuts_test.dart | 210 +++++++++++++++++++++++++++ 9 files changed, 532 insertions(+), 60 deletions(-) create mode 100644 lib/shortcuts.dart create mode 100644 test/note_editor_shortcuts_test.dart diff --git a/lib/main.dart b/lib/main.dart index 0cf2bf6..a86932b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'themes/day.dart'; import 'themes/night.dart'; import 'note_list.dart'; +import 'shortcuts.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -72,6 +73,15 @@ class _MuzzleVelocityAppState extends State { textTheme: GoogleFonts.ibmPlexMonoTextTheme(nightText), ), themeMode: _themeMode, + // App-wide shortcuts; screens can override them with their own bindings. + builder: (context, child) => CallbackShortcuts( + bindings: { + quitShortcut: quitApp, + primaryShortcut(LogicalKeyboardKey.keyD): () => + _toggleTheme(_themeMode != ThemeMode.dark), + }, + child: child!, + ), home: NoteListScreen( toggleTheme: _toggleTheme, toggleTags: _toggleTags, diff --git a/lib/markdown_editing.dart b/lib/markdown_editing.dart index c310edc..8561828 100644 --- a/lib/markdown_editing.dart +++ b/lib/markdown_editing.dart @@ -110,7 +110,7 @@ class MarkdownController extends TextEditingController { _updateMatches(); } - final Map theme; + Map theme; List _terms = const []; List _matches = const []; @@ -375,3 +375,40 @@ TextEditingValue indentLines(TextEditingValue value, {bool outdent = false}) { ), ); } + +/// Toggles emphasis [marker] (`*` or `**`) around the selection, leaving the +/// selection on the same text. With nothing selected, inserts an empty pair. +TextEditingValue toggleEmphasis(TextEditingValue value, String marker) { + final sel = value.selection; + if (!sel.isValid) return value; + final text = value.text; + final s = sel.start; + final e = sel.end; + final char = marker[0]; + + var before = 0; + while (before < s && text[s - before - 1] == char) { + before++; + } + var after = 0; + while (e + after < text.length && text[e + after] == char) { + after++; + } + // A run of 3 is bold plus italic, so either marker can be removed from it. + final run = before < after ? before : after; + final present = marker.length == 2 ? run >= 2 : run == 1 || run == 3; + + final len = marker.length; + if (present) { + return TextEditingValue( + text: text.replaceRange(e, e + len, '').replaceRange(s - len, s, ''), + selection: TextSelection( + baseOffset: sel.baseOffset - len, extentOffset: sel.extentOffset - len), + ); + } + return TextEditingValue( + text: text.replaceRange(s, e, '$marker${text.substring(s, e)}$marker'), + selection: TextSelection( + baseOffset: sel.baseOffset + len, extentOffset: sel.extentOffset + len), + ); +} diff --git a/lib/markdown_view.dart b/lib/markdown_view.dart index 967c16b..e14f2ca 100644 --- a/lib/markdown_view.dart +++ b/lib/markdown_view.dart @@ -9,6 +9,7 @@ class MarkdownView extends StatefulWidget { final bool readOnly; final double fontSize; final FocusNode? focusNode; + final ScrollController? scrollController; const MarkdownView({ super.key, @@ -16,6 +17,7 @@ class MarkdownView extends StatefulWidget { this.readOnly = true, this.fontSize = 16, this.focusNode, + this.scrollController, }); @override @@ -40,6 +42,16 @@ class MarkdownViewState extends State { ); } + /// Text offset at the top-left of the visible area. + int firstVisibleOffset() { + final box = _fieldKey.currentContext?.findRenderObject() as RenderBox?; + final editable = _findEditable(box); + if (box == null || editable == null) return 0; + return editable + .getPositionForPoint(box.localToGlobal(const Offset(1, 1))) + .offset; + } + RenderEditable? _findEditable(RenderObject? node) { if (node is RenderEditable) return node; RenderEditable? found; @@ -68,6 +80,7 @@ class MarkdownViewState extends State { key: _fieldKey, controller: widget.controller, focusNode: widget.focusNode, + scrollController: widget.scrollController, readOnly: widget.readOnly, showCursor: !widget.readOnly, expands: true, diff --git a/lib/note_editor.dart b/lib/note_editor.dart index 7d3d2a2..4604a2a 100644 --- a/lib/note_editor.dart +++ b/lib/note_editor.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_markdown_latex/flutter_markdown_latex.dart'; import 'package:flutter_svg/svg.dart'; @@ -10,18 +11,19 @@ import 'themes/day.dart'; import 'themes/night.dart'; import 'markdown_editing.dart'; import 'markdown_view.dart'; +import 'shortcuts.dart'; class NoteEditorScreen extends StatefulWidget { final File note; final String? notesDirectoryPath; // ✅ Add this - final bool isDarkMode; final List searchTerms; + final Future Function() onQuit; NoteEditorScreen( {required this.note, - required this.isDarkMode, required this.notesDirectoryPath, - this.searchTerms = const []}); + this.searchTerms = const [], + this.onQuit = quitApp}); @override _NoteEditorScreenState createState() => _NoteEditorScreenState(); @@ -32,6 +34,9 @@ class _NoteEditorScreenState extends State { late String _initialContent; final _viewKey = GlobalKey(); final _editorFocus = FocusNode(); + final _screenFocus = FocusNode(); + final _sourceScroll = ScrollController(); + final _previewScroll = ScrollController(); bool _isEditing = false; bool _isPreviewMode = false; bool _isLoading = true; @@ -71,7 +76,7 @@ class _NoteEditorScreenState extends State { setState(() { _controller = MarkdownController( text: _initialContent, - theme: widget.isDarkMode ? nightTheme : dayTheme, + theme: _styles, )..search(widget.searchTerms); _controller.addListener(_onTextChanged); _isLoading = false; @@ -93,13 +98,112 @@ class _NoteEditorScreenState extends State { _isPreviewMode = false; }); if (_isEditing) { + // Without a caret, focusing would put it at the end and scroll there. + if (!_controller.selection.isValid) { + final offset = _viewKey.currentState?.firstVisibleOffset() ?? 0; + _controller.selection = TextSelection.collapsed(offset: offset); + } WidgetsBinding.instance .addPostFrameCallback((_) => _editorFocus.requestFocus()); } else { - _editorFocus.unfocus(); + // Keep focus inside the screen so its shortcuts stay active. + _screenFocus.requestFocus(); } } + void _togglePreview() { + setState(() { + _isPreviewMode = !_isPreviewMode; + }); + if (_isPreviewMode) _screenFocus.requestFocus(); + } + + void _stepFontSize(double step) { + _updateFontSize((_fontSize + step).clamp(12.0, 24.0)); + } + + void _toggleEmphasis(String marker) { + if (_isEditing && !_isPreviewMode) { + _controller.value = toggleEmphasis(_controller.value, marker); + } + } + + ScrollController get _activeScroll => + _isPreviewMode ? _previewScroll : _sourceScroll; + + void _scroll(double lines) => _scrollBy(lines * _fontSize * 1.5); + + void _scrollPage(int direction) { + if (!_activeScroll.hasClients) return; + // Keep a little of the previous page in view for context. + _scrollBy(direction * _activeScroll.position.viewportDimension * 0.9); + } + + void _scrollBy(double pixels) { + if (!_activeScroll.hasClients) return; + final position = _activeScroll.position; + _activeScroll.jumpTo((position.pixels + pixels) + .clamp(position.minScrollExtent, position.maxScrollExtent)); + } + + bool get _isDark => Theme.of(context).brightness == Brightness.dark; + + Map get _styles => _isDark ? nightTheme : dayTheme; + + Future _backToList() async { + if (await _onWillPop() && mounted) Navigator.pop(context); + } + + Future _quit() async { + if (await _onWillPop()) await widget.onQuit(); + } + + Future _escape() async { + if (_isPreviewMode) { + _togglePreview(); + } else if (_isEditing) { + _toggleEditing(); + } else { + await _backToList(); + } + } + + Map get _shortcuts { + const primary = primaryShortcut; + void previous() => _showMatch(_controller.currentMatch - 1); + void next() => _showMatch(_controller.currentMatch + 1); + + return { + quitShortcut: _quit, + primary(LogicalKeyboardKey.keyL): _backToList, + primary(LogicalKeyboardKey.keyE): _toggleEditing, + primary(LogicalKeyboardKey.keyS): _saveNote, + primary(LogicalKeyboardKey.keyP): _togglePreview, + const SingleActivator(LogicalKeyboardKey.escape): _escape, + primary(LogicalKeyboardKey.keyB): () => _toggleEmphasis('**'), + primary(LogicalKeyboardKey.keyI): () => _toggleEmphasis('*'), + const SingleActivator(LogicalKeyboardKey.f3): next, + const SingleActivator(LogicalKeyboardKey.f3, shift: true): previous, + primary(LogicalKeyboardKey.keyG): next, + primary(LogicalKeyboardKey.keyG, shift: true): previous, + primary(LogicalKeyboardKey.equal): () => _stepFontSize(2), + primary(LogicalKeyboardKey.equal, shift: true): () => _stepFontSize(2), + primary(LogicalKeyboardKey.numpadAdd): () => _stepFontSize(2), + primary(LogicalKeyboardKey.minus): () => _stepFontSize(-2), + primary(LogicalKeyboardKey.numpadSubtract): () => _stepFontSize(-2), + primary(LogicalKeyboardKey.digit0): () => _updateFontSize(16), + // In edit mode the arrows move the caret instead. + if (_isPreviewMode || !_isEditing) ...{ + const SingleActivator(LogicalKeyboardKey.arrowDown): () => _scroll(3), + const SingleActivator(LogicalKeyboardKey.arrowUp): () => _scroll(-3), + const SingleActivator(LogicalKeyboardKey.pageDown): () => + _scrollPage(1), + const SingleActivator(LogicalKeyboardKey.pageUp): () => + _scrollPage(-1), + }, + }; + } + void _onTextChanged() { if (_autoSaveEnabled && _controller.text != _initialContent) { _saveNote(); @@ -166,14 +270,24 @@ class _NoteEditorScreenState extends State { _controller.removeListener(_onTextChanged); _controller.dispose(); _editorFocus.dispose(); + _screenFocus.dispose(); + _sourceScroll.dispose(); + _previewScroll.dispose(); super.dispose(); } @override Widget build(BuildContext context) { + // Follow theme switches made while the note is open. + if (!_isLoading) _controller.theme = _styles; return WillPopScope( onWillPop: _onWillPop, - child: Scaffold( + child: CallbackShortcuts( + bindings: _isLoading ? const {} : _shortcuts, + child: Focus( + focusNode: _screenFocus, + autofocus: true, + child: Scaffold( appBar: AppBar( title: Text(widget.note.uri.pathSegments.last.replaceAll('.md', '')), leading: IconButton( @@ -210,9 +324,7 @@ class _NoteEditorScreenState extends State { _setAutoSaveEnabled(!_autoSaveEnabled); break; case 'Toggle Preview': - setState(() { - _isPreviewMode = !_isPreviewMode; - }); + _togglePreview(); break; } }, @@ -291,11 +403,12 @@ class _NoteEditorScreenState extends State { ? Padding( padding: const EdgeInsets.all(16.0), child: SingleChildScrollView( + controller: _previewScroll, child: MarkdownBody( builders: { 'latex': LatexElementBuilder( textStyle: TextStyle( - color: widget.isDarkMode + color: _isDark ? Colors.white : Colors.black)), }, @@ -308,24 +421,24 @@ class _NoteEditorScreenState extends State { styleSheet: MarkdownStyleSheet( p: TextStyle( fontSize: _fontSize, - color: widget.isDarkMode + color: _isDark ? Colors.white : Colors.black), h1: TextStyle( fontSize: _fontSize + 8, - color: widget.isDarkMode + color: _isDark ? Colors.white : Colors.black, fontWeight: FontWeight.bold), h2: TextStyle( fontSize: _fontSize + 6, - color: widget.isDarkMode + color: _isDark ? Colors.white : Colors.black, fontWeight: FontWeight.bold), h3: TextStyle( fontSize: _fontSize + 4, - color: widget.isDarkMode + color: _isDark ? Colors.white : Colors.black, fontWeight: FontWeight.bold), @@ -346,6 +459,7 @@ class _NoteEditorScreenState extends State { readOnly: !_isEditing, fontSize: _fontSize, focusNode: _editorFocus, + scrollController: _sourceScroll, ), ), floatingActionButton: _isLoading || _isPreviewMode @@ -373,6 +487,8 @@ class _NoteEditorScreenState extends State { ) : SizedBox.shrink(), ), + ), + ), ), ); } diff --git a/lib/note_list.dart b/lib/note_list.dart index 0546b00..c7f30b4 100644 --- a/lib/note_list.dart +++ b/lib/note_list.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -10,6 +11,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'note_editor.dart'; +import 'shortcuts.dart'; import 'read_only.dart'; import 'texts.dart'; @@ -45,6 +47,8 @@ class _NoteListScreenState extends State { bool _includeFileContent = false; bool _includeSubdirectories = true; Timer? _debounce; + int? _highlighted; // Note picked with the arrow keys, opened by Enter. + final _highlightedKey = GlobalKey(); @override void initState() { @@ -321,6 +325,7 @@ Happy note taking! ✨ final query = searchController.text.trim().toLowerCase(); setState(() { + _highlighted = null; if (query.isEmpty) { filteredNotes = List.from(notes); return; @@ -361,6 +366,71 @@ Happy note taking! ✨ return [if (text.isNotEmpty) text, ...tags]; } + void _moveHighlight(int step) { + if (filteredNotes.isEmpty) return; + final next = (_highlighted ?? -1) + step; + setState(() { + // Moving up past the first note clears the highlight, so Enter creates a note again. + _highlighted = next < 0 ? null : next.clamp(0, filteredNotes.length - 1); + if (_highlighted != null && _highlighted! >= _visibleLimit) { + _visibleLimit = _highlighted! + 1; + } + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + final context = _highlightedKey.currentContext; + if (context != null) { + Scrollable.ensureVisible(context, + alignmentPolicy: step > 0 + ? ScrollPositionAlignmentPolicy.keepVisibleAtEnd + : ScrollPositionAlignmentPolicy.keepVisibleAtStart); + } + }); + } + + void _focusSearch() { + searchFocusNode.requestFocus(); + searchController.selection = TextSelection( + baseOffset: 0, extentOffset: searchController.text.length); + } + + Future _openNote(File noteFile) async { + if (_useExternalEditor) { + try { + if (Platform.isLinux) { + final result = await Process.run('which', ['xdg-open']); + if (result.stdout.toString().trim().isNotEmpty) { + await Process.run('xdg-open', [noteFile.path]); + } else { + print("❌ `xdg-open` is not available."); + } + } else if (Platform.isAndroid) { + await _requestStoragePermission(); + final result = await OpenFilex.open( + noteFile.path, + type: "text/markdown", + ); + print("✅ OpenFilex result: ${result.type}, message: ${result.message}"); + } + } catch (e) { + print("❌ Failed to open external editor: $e"); + } + } else { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => NoteEditorScreen( + note: noteFile, + notesDirectoryPath: notesDirectoryPath, + searchTerms: _noteSearchTerms(), + ), + ), + ); + _loadNotes(); // ✅ Reload notes after returning + setState(() {}); // ✅ Force UI refresh + searchFocusNode.requestFocus(); + } + } + void _loadMoreNotes() { if (_isLoadingMore || _visibleNotes.length >= filteredNotes.length) return; @@ -463,7 +533,6 @@ Happy note taking! ✨ MaterialPageRoute( builder: (context) => NoteEditorScreen( note: newNote, - isDarkMode: widget.isDarkMode, notesDirectoryPath: notesDirectoryPath, ), ), @@ -665,7 +734,6 @@ Happy note taking! ✨ builder: (context) => ReadOnlyPage( title: title, content: content, - isDarkMode: widget.isDarkMode, ), ), ); @@ -673,11 +741,23 @@ Happy note taking! ✨ @override Widget build(BuildContext context) { - return Scaffold( + return CallbackShortcuts( + bindings: { + primaryShortcut(LogicalKeyboardKey.keyL): _focusSearch, + primaryShortcut(LogicalKeyboardKey.keyT): widget.toggleTags, + }, + child: Scaffold( appBar: AppBar( title: Padding( padding: EdgeInsets.symmetric(horizontal: 10), - child: TextField( + child: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.arrowDown): () => + _moveHighlight(1), + const SingleActivator(LogicalKeyboardKey.arrowUp): () => + _moveHighlight(-1), + }, + child: TextField( focusNode: searchFocusNode, enableInteractiveSelection: true, autocorrect: false, @@ -689,15 +769,20 @@ Happy note taking! ✨ border: InputBorder.none, ), style: GoogleFonts.ibmPlexMono(color: Colors.grey[300]), + // Keep focus after Enter so the list's shortcuts stay reachable. + onEditingComplete: () {}, onSubmitted: (text) { final trimmedText = text.trim(); - if (trimmedText.isNotEmpty && _isNewNote(trimmedText)) { + if (_highlighted != null) { + _openNote(filteredNotes[_highlighted!]); + } else if (trimmedText.isNotEmpty && _isNewNote(trimmedText)) { _createNewNote(trimmedText); searchController .clear(); // Clears the search field after creation } }, ), + ), ), actions: [ Padding( @@ -883,6 +968,10 @@ Happy note taking! ✨ ), onDismissed: (direction) => _deleteNote(noteFile), child: ListTile( + key: index == _highlighted ? _highlightedKey : null, + selected: index == _highlighted, + selectedColor: Colors.orange, + selectedTileColor: Colors.orange.withAlpha(30), title: Text(noteFile.uri.pathSegments.last.replaceAll('.md', '')), subtitle: LayoutBuilder( builder: (context, constraints) { @@ -912,49 +1001,14 @@ Happy note taking! ✨ ); }, ), - onTap: () async { - if (_useExternalEditor) { - try { - if (Platform.isLinux) { - final result = await Process.run('which', ['xdg-open']); - if (result.stdout.toString().trim().isNotEmpty) { - await Process.run('xdg-open', [noteFile.path]); - } else { - print("❌ `xdg-open` is not available."); - } - } else if (Platform.isAndroid) { - await _requestStoragePermission(); - final result = await OpenFilex.open( - noteFile.path, - type: "text/markdown", - ); - print("✅ OpenFilex result: ${result.type}, message: ${result.message}"); - } - } catch (e) { - print("❌ Failed to open external editor: $e"); - } - } else { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => NoteEditorScreen( - note: noteFile, - isDarkMode: widget.isDarkMode, - notesDirectoryPath: notesDirectoryPath, - searchTerms: _noteSearchTerms(), - ), - ), - ); - _loadNotes(); // ✅ Reload notes after returning - setState(() {}); // ✅ Force UI refresh - } - }, + onTap: () => _openNote(noteFile), ), ); }, ), ), ), + ), ); } } \ No newline at end of file diff --git a/lib/read_only.dart b/lib/read_only.dart index e318975..0dfcab8 100644 --- a/lib/read_only.dart +++ b/lib/read_only.dart @@ -7,12 +7,10 @@ import 'themes/night.dart'; class ReadOnlyPage extends StatelessWidget { final String title; final String content; - final bool isDarkMode; ReadOnlyPage({ required this.title, required this.content, - required this.isDarkMode, }); @override @@ -20,7 +18,9 @@ class ReadOnlyPage extends StatelessWidget { final view = MarkdownView( controller: MarkdownController( text: content, - theme: isDarkMode ? nightTheme : dayTheme, + theme: Theme.of(context).brightness == Brightness.dark + ? nightTheme + : dayTheme, ), ); diff --git a/lib/shortcuts.dart b/lib/shortcuts.dart new file mode 100644 index 0000000..54451e4 --- /dev/null +++ b/lib/shortcuts.dart @@ -0,0 +1,16 @@ +import 'dart:ui'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// [key] with Cmd on Apple platforms and Ctrl elsewhere. +SingleActivator primaryShortcut(LogicalKeyboardKey key, {bool shift = false}) { + final apple = defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.iOS; + return SingleActivator(key, control: !apple, meta: apple, shift: shift); +} + +final quitShortcut = primaryShortcut(LogicalKeyboardKey.keyQ); + +Future quitApp() => + ServicesBinding.instance.exitApplication(AppExitType.required); diff --git a/test/markdown_editing_test.dart b/test/markdown_editing_test.dart index 05b2e6a..a182253 100644 --- a/test/markdown_editing_test.dart +++ b/test/markdown_editing_test.dart @@ -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( diff --git a/test/note_editor_shortcuts_test.dart b/test/note_editor_shortcuts_test.dart new file mode 100644 index 0000000..da8a147 --- /dev/null +++ b/test/note_editor_shortcuts_test.dart @@ -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 _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 pumpEditor(WidgetTester tester, + {Future 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.delayed(const Duration(milliseconds: 100)); + }); + await tester.pumpAndSettle(); + return tester.widget(find.byType(TextField)); + } + + TextField field(WidgetTester tester) => + tester.widget(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(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.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.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(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); + }); +}