feat: add keyboard shortcuts for notes, the note list and the app
This commit is contained in:
parent
ed2de5e67f
commit
722390ecec
9 changed files with 532 additions and 60 deletions
|
|
@ -7,6 +7,7 @@ import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'themes/day.dart';
|
import 'themes/day.dart';
|
||||||
import 'themes/night.dart';
|
import 'themes/night.dart';
|
||||||
import 'note_list.dart';
|
import 'note_list.dart';
|
||||||
|
import 'shortcuts.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
@ -72,6 +73,15 @@ class _MuzzleVelocityAppState extends State<MuzzleVelocityApp> {
|
||||||
textTheme: GoogleFonts.ibmPlexMonoTextTheme(nightText),
|
textTheme: GoogleFonts.ibmPlexMonoTextTheme(nightText),
|
||||||
),
|
),
|
||||||
themeMode: _themeMode,
|
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(
|
home: NoteListScreen(
|
||||||
toggleTheme: _toggleTheme,
|
toggleTheme: _toggleTheme,
|
||||||
toggleTags: _toggleTags,
|
toggleTags: _toggleTags,
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ class MarkdownController extends TextEditingController {
|
||||||
_updateMatches();
|
_updateMatches();
|
||||||
}
|
}
|
||||||
|
|
||||||
final Map<String, TextStyle> theme;
|
Map<String, TextStyle> theme;
|
||||||
|
|
||||||
List<String> _terms = const [];
|
List<String> _terms = const [];
|
||||||
List<TextRange> _matches = const [];
|
List<TextRange> _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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ class MarkdownView extends StatefulWidget {
|
||||||
final bool readOnly;
|
final bool readOnly;
|
||||||
final double fontSize;
|
final double fontSize;
|
||||||
final FocusNode? focusNode;
|
final FocusNode? focusNode;
|
||||||
|
final ScrollController? scrollController;
|
||||||
|
|
||||||
const MarkdownView({
|
const MarkdownView({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -16,6 +17,7 @@ class MarkdownView extends StatefulWidget {
|
||||||
this.readOnly = true,
|
this.readOnly = true,
|
||||||
this.fontSize = 16,
|
this.fontSize = 16,
|
||||||
this.focusNode,
|
this.focusNode,
|
||||||
|
this.scrollController,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -40,6 +42,16 @@ class MarkdownViewState extends State<MarkdownView> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
RenderEditable? _findEditable(RenderObject? node) {
|
||||||
if (node is RenderEditable) return node;
|
if (node is RenderEditable) return node;
|
||||||
RenderEditable? found;
|
RenderEditable? found;
|
||||||
|
|
@ -68,6 +80,7 @@ class MarkdownViewState extends State<MarkdownView> {
|
||||||
key: _fieldKey,
|
key: _fieldKey,
|
||||||
controller: widget.controller,
|
controller: widget.controller,
|
||||||
focusNode: widget.focusNode,
|
focusNode: widget.focusNode,
|
||||||
|
scrollController: widget.scrollController,
|
||||||
readOnly: widget.readOnly,
|
readOnly: widget.readOnly,
|
||||||
showCursor: !widget.readOnly,
|
showCursor: !widget.readOnly,
|
||||||
expands: true,
|
expands: true,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
import 'package:flutter_markdown_latex/flutter_markdown_latex.dart';
|
import 'package:flutter_markdown_latex/flutter_markdown_latex.dart';
|
||||||
import 'package:flutter_svg/svg.dart';
|
import 'package:flutter_svg/svg.dart';
|
||||||
|
|
@ -10,18 +11,19 @@ import 'themes/day.dart';
|
||||||
import 'themes/night.dart';
|
import 'themes/night.dart';
|
||||||
import 'markdown_editing.dart';
|
import 'markdown_editing.dart';
|
||||||
import 'markdown_view.dart';
|
import 'markdown_view.dart';
|
||||||
|
import 'shortcuts.dart';
|
||||||
|
|
||||||
class NoteEditorScreen extends StatefulWidget {
|
class NoteEditorScreen extends StatefulWidget {
|
||||||
final File note;
|
final File note;
|
||||||
final String? notesDirectoryPath; // ✅ Add this
|
final String? notesDirectoryPath; // ✅ Add this
|
||||||
final bool isDarkMode;
|
|
||||||
final List<String> searchTerms;
|
final List<String> searchTerms;
|
||||||
|
final Future<void> Function() onQuit;
|
||||||
|
|
||||||
NoteEditorScreen(
|
NoteEditorScreen(
|
||||||
{required this.note,
|
{required this.note,
|
||||||
required this.isDarkMode,
|
|
||||||
required this.notesDirectoryPath,
|
required this.notesDirectoryPath,
|
||||||
this.searchTerms = const []});
|
this.searchTerms = const [],
|
||||||
|
this.onQuit = quitApp});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_NoteEditorScreenState createState() => _NoteEditorScreenState();
|
_NoteEditorScreenState createState() => _NoteEditorScreenState();
|
||||||
|
|
@ -32,6 +34,9 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||||
late String _initialContent;
|
late String _initialContent;
|
||||||
final _viewKey = GlobalKey<MarkdownViewState>();
|
final _viewKey = GlobalKey<MarkdownViewState>();
|
||||||
final _editorFocus = FocusNode();
|
final _editorFocus = FocusNode();
|
||||||
|
final _screenFocus = FocusNode();
|
||||||
|
final _sourceScroll = ScrollController();
|
||||||
|
final _previewScroll = ScrollController();
|
||||||
bool _isEditing = false;
|
bool _isEditing = false;
|
||||||
bool _isPreviewMode = false;
|
bool _isPreviewMode = false;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
|
@ -71,7 +76,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||||
setState(() {
|
setState(() {
|
||||||
_controller = MarkdownController(
|
_controller = MarkdownController(
|
||||||
text: _initialContent,
|
text: _initialContent,
|
||||||
theme: widget.isDarkMode ? nightTheme : dayTheme,
|
theme: _styles,
|
||||||
)..search(widget.searchTerms);
|
)..search(widget.searchTerms);
|
||||||
_controller.addListener(_onTextChanged);
|
_controller.addListener(_onTextChanged);
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
|
|
@ -93,13 +98,112 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||||
_isPreviewMode = false;
|
_isPreviewMode = false;
|
||||||
});
|
});
|
||||||
if (_isEditing) {
|
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
|
WidgetsBinding.instance
|
||||||
.addPostFrameCallback((_) => _editorFocus.requestFocus());
|
.addPostFrameCallback((_) => _editorFocus.requestFocus());
|
||||||
} else {
|
} 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<String, TextStyle> get _styles => _isDark ? nightTheme : dayTheme;
|
||||||
|
|
||||||
|
Future<void> _backToList() async {
|
||||||
|
if (await _onWillPop() && mounted) Navigator.pop(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _quit() async {
|
||||||
|
if (await _onWillPop()) await widget.onQuit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _escape() async {
|
||||||
|
if (_isPreviewMode) {
|
||||||
|
_togglePreview();
|
||||||
|
} else if (_isEditing) {
|
||||||
|
_toggleEditing();
|
||||||
|
} else {
|
||||||
|
await _backToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<ShortcutActivator, VoidCallback> 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() {
|
void _onTextChanged() {
|
||||||
if (_autoSaveEnabled && _controller.text != _initialContent) {
|
if (_autoSaveEnabled && _controller.text != _initialContent) {
|
||||||
_saveNote();
|
_saveNote();
|
||||||
|
|
@ -166,13 +270,23 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||||
_controller.removeListener(_onTextChanged);
|
_controller.removeListener(_onTextChanged);
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
_editorFocus.dispose();
|
_editorFocus.dispose();
|
||||||
|
_screenFocus.dispose();
|
||||||
|
_sourceScroll.dispose();
|
||||||
|
_previewScroll.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// Follow theme switches made while the note is open.
|
||||||
|
if (!_isLoading) _controller.theme = _styles;
|
||||||
return WillPopScope(
|
return WillPopScope(
|
||||||
onWillPop: _onWillPop,
|
onWillPop: _onWillPop,
|
||||||
|
child: CallbackShortcuts(
|
||||||
|
bindings: _isLoading ? const {} : _shortcuts,
|
||||||
|
child: Focus(
|
||||||
|
focusNode: _screenFocus,
|
||||||
|
autofocus: true,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(widget.note.uri.pathSegments.last.replaceAll('.md', '')),
|
title: Text(widget.note.uri.pathSegments.last.replaceAll('.md', '')),
|
||||||
|
|
@ -210,9 +324,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||||
_setAutoSaveEnabled(!_autoSaveEnabled);
|
_setAutoSaveEnabled(!_autoSaveEnabled);
|
||||||
break;
|
break;
|
||||||
case 'Toggle Preview':
|
case 'Toggle Preview':
|
||||||
setState(() {
|
_togglePreview();
|
||||||
_isPreviewMode = !_isPreviewMode;
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -291,11 +403,12 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
|
controller: _previewScroll,
|
||||||
child: MarkdownBody(
|
child: MarkdownBody(
|
||||||
builders: {
|
builders: {
|
||||||
'latex': LatexElementBuilder(
|
'latex': LatexElementBuilder(
|
||||||
textStyle: TextStyle(
|
textStyle: TextStyle(
|
||||||
color: widget.isDarkMode
|
color: _isDark
|
||||||
? Colors.white
|
? Colors.white
|
||||||
: Colors.black)),
|
: Colors.black)),
|
||||||
},
|
},
|
||||||
|
|
@ -308,24 +421,24 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||||
styleSheet: MarkdownStyleSheet(
|
styleSheet: MarkdownStyleSheet(
|
||||||
p: TextStyle(
|
p: TextStyle(
|
||||||
fontSize: _fontSize,
|
fontSize: _fontSize,
|
||||||
color: widget.isDarkMode
|
color: _isDark
|
||||||
? Colors.white
|
? Colors.white
|
||||||
: Colors.black),
|
: Colors.black),
|
||||||
h1: TextStyle(
|
h1: TextStyle(
|
||||||
fontSize: _fontSize + 8,
|
fontSize: _fontSize + 8,
|
||||||
color: widget.isDarkMode
|
color: _isDark
|
||||||
? Colors.white
|
? Colors.white
|
||||||
: Colors.black,
|
: Colors.black,
|
||||||
fontWeight: FontWeight.bold),
|
fontWeight: FontWeight.bold),
|
||||||
h2: TextStyle(
|
h2: TextStyle(
|
||||||
fontSize: _fontSize + 6,
|
fontSize: _fontSize + 6,
|
||||||
color: widget.isDarkMode
|
color: _isDark
|
||||||
? Colors.white
|
? Colors.white
|
||||||
: Colors.black,
|
: Colors.black,
|
||||||
fontWeight: FontWeight.bold),
|
fontWeight: FontWeight.bold),
|
||||||
h3: TextStyle(
|
h3: TextStyle(
|
||||||
fontSize: _fontSize + 4,
|
fontSize: _fontSize + 4,
|
||||||
color: widget.isDarkMode
|
color: _isDark
|
||||||
? Colors.white
|
? Colors.white
|
||||||
: Colors.black,
|
: Colors.black,
|
||||||
fontWeight: FontWeight.bold),
|
fontWeight: FontWeight.bold),
|
||||||
|
|
@ -346,6 +459,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||||
readOnly: !_isEditing,
|
readOnly: !_isEditing,
|
||||||
fontSize: _fontSize,
|
fontSize: _fontSize,
|
||||||
focusNode: _editorFocus,
|
focusNode: _editorFocus,
|
||||||
|
scrollController: _sourceScroll,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
floatingActionButton: _isLoading || _isPreviewMode
|
floatingActionButton: _isLoading || _isPreviewMode
|
||||||
|
|
@ -374,6 +488,8 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||||
: SizedBox.shrink(),
|
: SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:google_fonts/google_fonts.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:path_provider/path_provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'note_editor.dart';
|
import 'note_editor.dart';
|
||||||
|
import 'shortcuts.dart';
|
||||||
import 'read_only.dart';
|
import 'read_only.dart';
|
||||||
import 'texts.dart';
|
import 'texts.dart';
|
||||||
|
|
||||||
|
|
@ -45,6 +47,8 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
||||||
bool _includeFileContent = false;
|
bool _includeFileContent = false;
|
||||||
bool _includeSubdirectories = true;
|
bool _includeSubdirectories = true;
|
||||||
Timer? _debounce;
|
Timer? _debounce;
|
||||||
|
int? _highlighted; // Note picked with the arrow keys, opened by Enter.
|
||||||
|
final _highlightedKey = GlobalKey();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -321,6 +325,7 @@ Happy note taking! ✨
|
||||||
final query = searchController.text.trim().toLowerCase();
|
final query = searchController.text.trim().toLowerCase();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_highlighted = null;
|
||||||
if (query.isEmpty) {
|
if (query.isEmpty) {
|
||||||
filteredNotes = List.from(notes);
|
filteredNotes = List.from(notes);
|
||||||
return;
|
return;
|
||||||
|
|
@ -361,6 +366,71 @@ Happy note taking! ✨
|
||||||
return [if (text.isNotEmpty) text, ...tags];
|
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<void> _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() {
|
void _loadMoreNotes() {
|
||||||
if (_isLoadingMore || _visibleNotes.length >= filteredNotes.length) return;
|
if (_isLoadingMore || _visibleNotes.length >= filteredNotes.length) return;
|
||||||
|
|
||||||
|
|
@ -463,7 +533,6 @@ Happy note taking! ✨
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => NoteEditorScreen(
|
builder: (context) => NoteEditorScreen(
|
||||||
note: newNote,
|
note: newNote,
|
||||||
isDarkMode: widget.isDarkMode,
|
|
||||||
notesDirectoryPath: notesDirectoryPath,
|
notesDirectoryPath: notesDirectoryPath,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -665,7 +734,6 @@ Happy note taking! ✨
|
||||||
builder: (context) => ReadOnlyPage(
|
builder: (context) => ReadOnlyPage(
|
||||||
title: title,
|
title: title,
|
||||||
content: content,
|
content: content,
|
||||||
isDarkMode: widget.isDarkMode,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -673,10 +741,22 @@ Happy note taking! ✨
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return CallbackShortcuts(
|
||||||
|
bindings: {
|
||||||
|
primaryShortcut(LogicalKeyboardKey.keyL): _focusSearch,
|
||||||
|
primaryShortcut(LogicalKeyboardKey.keyT): widget.toggleTags,
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Padding(
|
title: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 10),
|
padding: EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
child: CallbackShortcuts(
|
||||||
|
bindings: {
|
||||||
|
const SingleActivator(LogicalKeyboardKey.arrowDown): () =>
|
||||||
|
_moveHighlight(1),
|
||||||
|
const SingleActivator(LogicalKeyboardKey.arrowUp): () =>
|
||||||
|
_moveHighlight(-1),
|
||||||
|
},
|
||||||
child: TextField(
|
child: TextField(
|
||||||
focusNode: searchFocusNode,
|
focusNode: searchFocusNode,
|
||||||
enableInteractiveSelection: true,
|
enableInteractiveSelection: true,
|
||||||
|
|
@ -689,9 +769,13 @@ Happy note taking! ✨
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
),
|
),
|
||||||
style: GoogleFonts.ibmPlexMono(color: Colors.grey[300]),
|
style: GoogleFonts.ibmPlexMono(color: Colors.grey[300]),
|
||||||
|
// Keep focus after Enter so the list's shortcuts stay reachable.
|
||||||
|
onEditingComplete: () {},
|
||||||
onSubmitted: (text) {
|
onSubmitted: (text) {
|
||||||
final trimmedText = text.trim();
|
final trimmedText = text.trim();
|
||||||
if (trimmedText.isNotEmpty && _isNewNote(trimmedText)) {
|
if (_highlighted != null) {
|
||||||
|
_openNote(filteredNotes[_highlighted!]);
|
||||||
|
} else if (trimmedText.isNotEmpty && _isNewNote(trimmedText)) {
|
||||||
_createNewNote(trimmedText);
|
_createNewNote(trimmedText);
|
||||||
searchController
|
searchController
|
||||||
.clear(); // Clears the search field after creation
|
.clear(); // Clears the search field after creation
|
||||||
|
|
@ -699,6 +783,7 @@ Happy note taking! ✨
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 15),
|
padding: EdgeInsets.symmetric(horizontal: 15),
|
||||||
|
|
@ -883,6 +968,10 @@ Happy note taking! ✨
|
||||||
),
|
),
|
||||||
onDismissed: (direction) => _deleteNote(noteFile),
|
onDismissed: (direction) => _deleteNote(noteFile),
|
||||||
child: ListTile(
|
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', '')),
|
title: Text(noteFile.uri.pathSegments.last.replaceAll('.md', '')),
|
||||||
subtitle: LayoutBuilder(
|
subtitle: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
|
@ -912,49 +1001,14 @@ Happy note taking! ✨
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () => _openNote(noteFile),
|
||||||
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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7,12 +7,10 @@ import 'themes/night.dart';
|
||||||
class ReadOnlyPage extends StatelessWidget {
|
class ReadOnlyPage extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final String content;
|
final String content;
|
||||||
final bool isDarkMode;
|
|
||||||
|
|
||||||
ReadOnlyPage({
|
ReadOnlyPage({
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.content,
|
required this.content,
|
||||||
required this.isDarkMode,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -20,7 +18,9 @@ class ReadOnlyPage extends StatelessWidget {
|
||||||
final view = MarkdownView(
|
final view = MarkdownView(
|
||||||
controller: MarkdownController(
|
controller: MarkdownController(
|
||||||
text: content,
|
text: content,
|
||||||
theme: isDarkMode ? nightTheme : dayTheme,
|
theme: Theme.of(context).brightness == Brightness.dark
|
||||||
|
? nightTheme
|
||||||
|
: dayTheme,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
16
lib/shortcuts.dart
Normal file
16
lib/shortcuts.dart
Normal file
|
|
@ -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<void> quitApp() =>
|
||||||
|
ServicesBinding.instance.exitApplication(AppExitType.required);
|
||||||
|
|
@ -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', () {
|
group('highlightMarkdown', () {
|
||||||
test('line and inline syntax', () {
|
test('line and inline syntax', () {
|
||||||
final classes = _classes(
|
final classes = _classes(
|
||||||
|
|
|
||||||
210
test/note_editor_shortcuts_test.dart
Normal file
210
test/note_editor_shortcuts_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue