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

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