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

@ -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<MuzzleVelocityApp> {
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,

View file

@ -110,7 +110,7 @@ class MarkdownController extends TextEditingController {
_updateMatches();
}
final Map<String, TextStyle> theme;
Map<String, TextStyle> theme;
List<String> _terms = 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),
);
}

View file

@ -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<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) {
if (node is RenderEditable) return node;
RenderEditable? found;
@ -68,6 +80,7 @@ class MarkdownViewState extends State<MarkdownView> {
key: _fieldKey,
controller: widget.controller,
focusNode: widget.focusNode,
scrollController: widget.scrollController,
readOnly: widget.readOnly,
showCursor: !widget.readOnly,
expands: true,

View file

@ -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<String> searchTerms;
final Future<void> 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<NoteEditorScreen> {
late String _initialContent;
final _viewKey = GlobalKey<MarkdownViewState>();
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<NoteEditorScreen> {
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<NoteEditorScreen> {
_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<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() {
if (_autoSaveEnabled && _controller.text != _initialContent) {
_saveNote();
@ -166,14 +270,24 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
_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<NoteEditorScreen> {
_setAutoSaveEnabled(!_autoSaveEnabled);
break;
case 'Toggle Preview':
setState(() {
_isPreviewMode = !_isPreviewMode;
});
_togglePreview();
break;
}
},
@ -291,11 +403,12 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
? 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<NoteEditorScreen> {
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<NoteEditorScreen> {
readOnly: !_isEditing,
fontSize: _fontSize,
focusNode: _editorFocus,
scrollController: _sourceScroll,
),
),
floatingActionButton: _isLoading || _isPreviewMode
@ -373,6 +487,8 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
)
: SizedBox.shrink(),
),
),
),
),
);
}

View file

@ -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<NoteListScreen> {
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<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() {
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),
),
);
},
),
),
),
),
);
}
}

View file

@ -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,
),
);

16
lib/shortcuts.dart Normal file
View 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);