subwrite/lib/file.dart

733 lines
24 KiB
Dart

import 'dart:collection';
import 'dart:io';
import 'dart:async';
import 'dart:isolate';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:subwrite/annotation.dart';
import 'package:subwrite/section.dart';
import 'package:subwrite/sourcelink.dart';
import 'package:subwrite/spellcheck/spellchecker.dart';
import 'package:subwrite/view.dart';
import 'cursor_provider.dart';
import 'line_info.dart';
class LineFile extends ChangeNotifier {
List<LineInfo> backingLines = List.empty(growable: true);
CursorProvider cursor = CursorProvider();
FocusNode focus = FocusNode();
late String path;
ItemScrollController scrollController = ItemScrollController();
var status = "";
var showSuggestions = false;
SpellChecker? spellchecker;
buildDictionary() async {
ReceivePort receivePort = ReceivePort();
receivePort.listen((dynamic message) {
updateStatus(message as double);
});
status = "loading dictionary";
notifyListeners();
compute(SpellChecker.load, DictionaryLoadRequest("dicts/english.txt", receivePort.sendPort)).then(
(value) {
spellchecker = value;
status = "dictionary loaded";
status = "spellchecking document";
updateStatus(0.0);
notifyListeners();
spellCheckAll().then((value) {
status = "spellchecking complete";
updateStatus(1.0);
notifyListeners();
});
notifyListeners();
},
);
}
void updateStatus(double update) {
statusProgress = update;
print("New Progress Status...$statusProgress");
notifyListeners();
}
double statusProgress = 0.0;
void cancelSuggestions() {
suggestionIndex = 0;
suggestLine = 0;
suggestCol = 0;
showSuggestions = false;
notifyListeners();
}
int suggestLine = 0;
int suggestCol = 0;
void showSuggestionsFor(int line, int col) {
if (showSuggestions == false) {
if (line != suggestLine || col != suggestCol) {
suggestLine = line;
suggestCol = col;
showSuggestions = true;
loadSuggestions();
notifyListeners();
}
}
}
ScrollController suggestionListController = ScrollController(initialScrollOffset: 0.0);
int suggestionIndex = 0;
List<Suggestion> suggestions = List.empty();
void suggestListUp() {
suggestionIndex = (suggestionIndex - 1) % suggestions.length;
if (suggestions.length - suggestionIndex >= 14 || suggestionIndex == 0 || suggestionIndex == suggestions.length - 1) {
suggestionListController.animateTo(suggestionIndex * 13.0, duration: const Duration(milliseconds: 100), curve: Curves.decelerate);
}
notifyListeners();
}
void suggestListDown() {
suggestionIndex = (suggestionIndex + 1) % suggestions.length;
if (suggestions.length - suggestionIndex >= 14 || suggestionIndex == 0 || suggestionIndex == suggestions.length - 1) {
suggestionListController.animateTo(suggestionIndex * 13.0, duration: const Duration(milliseconds: 100), curve: Curves.decelerate);
}
notifyListeners();
}
void suggestListInsert() {
// TODO replace
}
void loadSuggestions() {
List<Suggestion> suggest = List.empty(growable: true);
if (suggestLine > 0) {
List<LineAnnotation> suggestions = backingLines[suggestLine - 1]
.annotations
.where((element) => element.tool == "spellcheck")
.where((element) => suggestCol >= element.sourceLink.colStart! && suggestCol < element.sourceLink.colEnd!)
.toList();
if (suggestions.isNotEmpty) {
var words = suggestions.first.description!.split(":");
for (var i = 0; i < words.length - 1; i++) {
var spell = words[i];
if (spell.trim().isNotEmpty) {
suggest.add(Suggestion("spelling", spell, correctSpelling, suggestions.first.sourceLink));
}
}
suggest.add(Suggestion("addto", words[words.length - 1], customDic, suggestions.first.sourceLink));
this.suggestions = suggest;
notifyListeners();
}
}
}
void openFile(String path, int startLine) {
// reset path
this.path = path;
// purge backinglines...
backingLines = List.empty(growable: true);
status = "Opened $path";
File f = File(path);
var line = 0;
f.readAsLinesSync().forEach((text) {
backingLines.insert(line, LineInfo(path, line + 1, text.replaceAll("\t", " ").trimRight()));
line += 1;
});
File notesFile = File("$path.subwrite.notes");
if (notesFile.existsSync()) {
notesFile.readAsLinesSync().forEach((notetext) {
var parts = notetext.split(":");
var line = int.parse(parts[0]);
var note = parts.sublist(1).join(":");
addNoteAtLine(line, note);
});
}
// handle the empty file case
if (backingLines.isEmpty) {
backingLines.insert(0, LineInfo(path, 1, ""));
}
parse();
cursor.moveCursorToDocStart();
focus.requestFocus();
notifyListeners();
}
KeyEventResult handleKey(RawKeyEvent event) {
// only process key down events...
if (event.runtimeType.toString() == 'RawKeyDownEvent') {
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
cursor.moveCursor(-1, 0, backingLines, keepAnchor: event.isShiftPressed);
cursor.publishCursor(backingLines);
cancelSuggestions();
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
cursor.moveCursor(1, 0, backingLines, keepAnchor: event.isShiftPressed);
cursor.publishCursor(backingLines);
cancelSuggestions();
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
if (showSuggestions == false) {
cursor.moveCursor(0, -1, backingLines, keepAnchor: event.isShiftPressed);
cursor.publishCursor(backingLines);
} else {
suggestListUp();
}
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
if (showSuggestions == false) {
cursor.moveCursor(0, 1, backingLines, keepAnchor: event.isShiftPressed);
cursor.publishCursor(backingLines);
} else {
suggestListDown();
}
} else if (event.logicalKey == LogicalKeyboardKey.home) {
cancelSuggestions();
cursor.moveCursorToLineStart();
cursor.clearSelection();
cursor.publishCursor(backingLines);
} else if (event.logicalKey == LogicalKeyboardKey.end) {
cancelSuggestions();
cursor.moveCursorToLineEnd(backingLines[cursor.line - 1].text.length);
cursor.clearSelection();
cursor.publishCursor(backingLines);
} else if (event.logicalKey == LogicalKeyboardKey.enter) {
if (showSuggestions == false) {
insertLine();
cursor.clearSelection();
cursor.publishCursor(backingLines);
} else {
suggestListInsert();
}
} else if (event.logicalKey == LogicalKeyboardKey.tab) {
insertChar(" ");
cursor.publishCursor(backingLines);
cancelSuggestions();
} else if (event.logicalKey == LogicalKeyboardKey.delete) {
handleDelete();
cursor.clearSelection();
cursor.publishCursor(backingLines);
cancelSuggestions();
} else if (event.logicalKey == LogicalKeyboardKey.backspace) {
handleBackspace();
cursor.clearSelection();
cursor.publishCursor(backingLines);
cancelSuggestions();
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
showSuggestions = false;
notifyListeners();
} else if (event.logicalKey == LogicalKeyboardKey.f4) {
status = "spellchecking document";
notifyListeners();
updateStatus(0.0);
spellCheckAll().then((value) {
status = "spellcheck complete";
notifyListeners();
});
} else {
if (event.isControlPressed) {
if (event.logicalKey == LogicalKeyboardKey.space) {
notifyListeners();
}
if (event.character != null && event.character == 's') {
saveFile();
notifyListeners();
}
if (event.character != null && event.character == 'c') {
copy();
}
if (event.character != null && event.character == 'v') {
paste();
notifyListeners();
}
} else {
if (event.logicalKey == LogicalKeyboardKey.space) {
insertChar(" ");
} else if (event.character != null) {
insertChar(event.character!);
}
}
cursor.publishCursor(backingLines);
}
}
parse();
spellcheck(cursor.line - 1).then((value) {
notifyListeners();
});
return KeyEventResult.handled;
}
void saveFile() {
status = "Saving...";
notifyListeners();
String content = '';
String notes = '';
for (var l in backingLines) {
content += '${l.text}\n';
for (var annot in l.annotations) {
if (annot.description != null && annot.tool != "spellcheck") {
notes += '${annot.sourceLink.lineStart}:${annot.description}\n';
}
}
}
File("$path.subwrite.notes").writeAsStringSync(notes);
File(path).writeAsStringSync(content);
status = "Saved";
notifyListeners();
}
void handleDelete() {
if (cursor.hasSelection()) {
deleteSelected();
return;
}
// if at the end of the line then we need to move the next line onto
// our line, and delete what remains
if (cursor.column == backingLines[cursor.line - 1].text.length) {
backingLines[cursor.line - 1].text += backingLines[cursor.line].text;
backingLines[cursor.line - 1].notifyListeners();
backingLines.removeAt(cursor.line);
renumberFrom(cursor.line);
} else {
// otherwise simply delete a char
var beforeDeletedChar = backingLines[cursor.line - 1].text.substring(0, cursor.column);
var afterDeletedChar = backingLines[cursor.line - 1].text.substring(cursor.column + 1);
backingLines[cursor.line - 1].text = beforeDeletedChar + afterDeletedChar;
backingLines[cursor.line - 1].notifyListeners();
}
}
void deleteSelected() {
var ncursor = cursor.normalized();
if (ncursor.hasSelection()) {
if (ncursor.anchorLine == ncursor.line) {
// simple case, just trim column
var beforeDeletedChar = backingLines[ncursor.line - 1].text.substring(0, ncursor.column);
var afterDeletedChar = backingLines[ncursor.line - 1].text.substring(ncursor.anchorColumn);
backingLines[ncursor.line - 1].text = beforeDeletedChar + afterDeletedChar;
backingLines[ncursor.line - 1].notifyListeners();
cursor.clearSelection();
} else {
// First Line
var withDeletedRemainder = backingLines[ncursor.line - 1].text.substring(0, ncursor.column);
var withDeletedPrefix = backingLines[ncursor.anchorLine - 1].text.substring(ncursor.anchorColumn);
backingLines[ncursor.line - 1].text = withDeletedRemainder + withDeletedPrefix;
// Delete 2nd to n-1 Lines
for (var line = (ncursor.anchorLine - 1); line >= ncursor.line; line -= 1) {
backingLines.removeAt(line);
}
renumberFrom(ncursor.line);
cursor.clearSelection();
}
}
cursor.clearSelection();
}
void handleBackspace() {
if (cursor.hasSelection()) {
deleteSelected();
return;
}
if (cursor.column > 0) {
var beforeDeletedChar = backingLines[cursor.line - 1].text.substring(0, cursor.column - 1);
var afterDeletedChar = backingLines[cursor.line - 1].text.substring(cursor.column);
backingLines[cursor.line - 1].text = beforeDeletedChar + afterDeletedChar;
backingLines[cursor.line - 1].notifyListeners();
cursor.moveCursor(-1, 0, backingLines);
} else if (cursor.line > 1) {
// join current line to previous line
cursor.moveCursor(0, -1, backingLines);
cursor.moveCursorToLineEnd(backingLines[cursor.line - 1].text.length);
backingLines[cursor.line - 1].text += backingLines[cursor.line].text;
backingLines[cursor.line - 1].notifyListeners();
// delete current line
backingLines.removeAt(cursor.line);
renumberFrom(cursor.line);
// renumber
}
}
void insertChar(String ch) {
status = "Unsaved Changes";
notifyListeners();
var beforeInsertedChar = backingLines[cursor.line - 1].text.substring(0, cursor.column);
var afterInsertedChar = backingLines[cursor.line - 1].text.substring(cursor.column);
backingLines[cursor.line - 1].text = beforeInsertedChar + ch + afterInsertedChar;
cursor.moveCursor(ch.length, 0, backingLines);
backingLines[cursor.line - 1].notify();
}
void insertLine() {
status = "Unsaved Changes";
notifyListeners();
// get the content that will be moved to the next line
var restOfLine = backingLines[cursor.line - 1].text.substring(cursor.column);
// split off the line after the cursor
backingLines[cursor.line - 1].text = backingLines[cursor.line - 1].text.substring(0, cursor.column);
// insert a line directly into this position
backingLines.insert(cursor.line, LineInfo(path, cursor.line + 1, restOfLine));
// Move the Cursor Down
cursor.moveCursor(0, 1, backingLines);
// renumber all the lines afterwards
renumberFrom(cursor.line - 1);
cursor.moveCursorToLineStart();
}
void renumberFrom(int startLine) {
// Catch many unsaved changes here...
status = "Unsaved Changes";
notifyListeners();
for (int line = startLine; line < backingLines.length; line++) {
backingLines[line].lineNumber = line + 1;
backingLines[line].notifyListeners();
}
notifyListeners();
}
int lines() {
return backingLines.length;
}
final _boldRegex = RegExp(
r'(\*\*([^*]*)\*\*)',
caseSensitive: false,
dotAll: true,
);
final _codeRegex = RegExp(
r'(\`([^`]*)\`)',
caseSensitive: false,
dotAll: true,
);
final _imageRegex = RegExp(
r'!\[([^\[]*)\]\((.*)\)',
caseSensitive: false,
dotAll: true,
);
List<Section> sections = List.empty(growable: true);
Future<void> spellcheck(int i) async {
var line = backingLines[i];
line.annotations.removeWhere((element) => element.tool == "spellcheck");
if (spellchecker == null) {
return;
}
List<LineAnnotation> annotations = List.empty(growable: true);
var wordStart = 0;
var wordEnd = 0;
var lowerCase = line.text.toLowerCase();
for (var c = 0; c < lowerCase.length; c++) {
if (lowerCase.substring(c, c + 1) == " ") {
wordEnd = c;
var annotationStart = wordStart;
var word = lowerCase.substring(wordStart, wordEnd);
wordStart = c + 1;
if (word.endsWith(".")) {
word = word.substring(0, word.length - 1);
}
if (word.endsWith(":")) {
word = word.substring(0, word.length - 1);
}
if (word.endsWith(";")) {
word = word.substring(0, word.length - 1);
}
if (word.endsWith(",")) {
word = word.substring(0, word.length - 1);
}
// clean up quotes
word = word.replaceAll("\"", "");
word = word.replaceAll("'", "");
word = word.replaceAll("`", "");
word = word.replaceAll("(", "");
word = word.replaceAll(")", "");
word = word.replaceAll("[", "");
word = word.replaceAll("]", "");
word = word.replaceAll("^", "");
word = word.replaceAll("*", "");
word = word.replaceAll("?", "");
word = word.replaceAll("!", "");
word = word.replaceAll("#", "");
word = word.replaceAll("*", "");
// ignore numbers
if (word.contains(RegExp(r'[0-9]'))) {
continue;
}
// ignore code...
if (word.contains("/") | word.contains("=") | word.contains(">") || word.contains("<") || word.contains("@") || word.contains("`") || word.contains(";")) {
continue;
}
if (word.length < 2 || word.length > 10) {
continue;
}
HashSet<String>? candidates = spellchecker!.candidates(word);
if (candidates == null) {
await compute(mutations2, word).then((results) {
spellchecker!.dictionary.putIfAbsent(word, () => HashSet());
results.forEach((element) {
if (spellchecker!.known.contains(element)) {
spellchecker!.dictionary[word]!.add(element);
}
});
});
}
// Candidates should now not be null...
candidates = spellchecker!.candidates(word);
if (candidates!.contains(word) == false) {
annotations.add(LineAnnotation(SourceLink("", lineStart: 0, colStart: annotationStart, colEnd: wordEnd + 1, lineEnd: 0), "spellcheck", AnnotationType.highlight, "spellcheck",
description: "${candidates.join(":")}:$word"));
}
}
for (var annot in annotations) {
annot.sourceLink = SourceLink(path, lineStart: i + 1, lineEnd: i + 1, colStart: annot.sourceLink.colStart, colEnd: annot.sourceLink.colEnd);
backingLines[i].annotations.add(annot);
}
notifyListeners();
}
return;
}
// rebuild the annotations manager
void parse() {
sections.clear();
for (var i = 0; i < backingLines.length; i++) {
var line = backingLines[i];
backingLines[i].annotations.removeWhere((element) => element.tool == "markdown");
if (line.text.startsWith("//")) {
backingLines[i]
.annotations
.add(LineAnnotation(SourceLink(path, lineStart: backingLines[i].lineNumber, colStart: 0, colEnd: line.text.length + 1, lineEnd: i), "markdown", AnnotationType.highlight, "comment"));
if (line.text.contains("TODO")) {
backingLines[i]
.annotations
.add(LineAnnotation(SourceLink(path, lineStart: backingLines[i].lineNumber, colStart: 0, colEnd: line.text.length + 1, lineEnd: i), "markdown", AnnotationType.todo, "todo"));
}
}
if (line.text.startsWith("#")) {
// this is a header
backingLines[i]
.annotations
.add(LineAnnotation(SourceLink(path, lineStart: backingLines[i].lineNumber, colStart: 0, colEnd: line.text.length + 1, lineEnd: i), "markdown", AnnotationType.highlight, "header"));
var level = 0;
for (var lvl = 0; lvl < 6; lvl++) {
if (line.text.length > lvl) {
if (line.text[lvl] == '#') {
level += 1;
} else {
break;
}
}
}
sections.add(Section(backingLines[i].lineNumber, level, line.text.substring(level + 1)));
}
if (line.text.startsWith(" ")) {
backingLines[i]
.annotations
.add(LineAnnotation(SourceLink(path, lineStart: backingLines[i].lineNumber, colStart: 0, colEnd: line.text.length + 1, lineEnd: i), "markdown", AnnotationType.highlight, "code"));
}
var imageMatch = _imageRegex.matchAsPrefix(line.text);
if (imageMatch != null) {
backingLines[i].annotations.add(LineAnnotation(SourceLink(path, lineStart: backingLines[i].lineNumber, lineEnd: i), "markdown", AnnotationType.image, imageMatch.group(2)!));
}
_boldRegex.allMatches(backingLines[i].text).forEach((match) {
backingLines[i]
.annotations
.add(LineAnnotation(SourceLink(path, lineStart: backingLines[i].lineNumber, colStart: match.start, colEnd: match.end + 1, lineEnd: i), "markdown", AnnotationType.highlight, "bold"));
});
_codeRegex.allMatches(backingLines[i].text).forEach((match) {
backingLines[i]
.annotations
.add(LineAnnotation(SourceLink(path, lineStart: backingLines[i].lineNumber, colStart: match.start, colEnd: match.end + 1, lineEnd: i), "markdown", AnnotationType.highlight, "code"));
});
}
notifyListeners();
}
void copy() {
var ncursor = cursor.normalized();
var firstLine = "${backingLines[ncursor.line - 1].text.substring(ncursor.column)}\n";
for (var line = ncursor.line + 1; line < ncursor.anchorLine; line++) {
firstLine += "${backingLines[line - 1].text}\n";
}
var textToCopy = firstLine;
if (ncursor.anchorLine != ncursor.line) {
var lastLine = backingLines[ncursor.anchorLine - 1].text.substring(0, ncursor.anchorColumn);
textToCopy += lastLine;
}
Clipboard.setData(ClipboardData(text: textToCopy));
}
void paste() {
deleteSelected(); // if we have a selection we need to delete it before pasting new text
Clipboard.getData("text/plain").then((value) {
if (value != null && value.text != null) {
for (var i = 0; i < value.text!.length; i++) {
var char = value.text![i];
if (char == "\n") {
insertLine();
} else {
insertChar(char);
}
}
}
});
}
void mouseDownUpdate(int line, int col) {
focus.requestFocus();
showSuggestions = false;
cursor.mouseDownUpdate(line, col, backingLines);
notifyListeners();
}
void mouseDownMoveUpdate(int col) {
cursor.mouseDownMoveUpdate(col, backingLines);
}
void mouseDownEnd() {
cursor.mouseDownEnd();
}
void moveLines(int startLine, int endLine, int newLine) {
List<LineInfo> insert = List.empty(growable: true);
// backup the section
for (int line = startLine; line < endLine; line++) {
insert.add(backingLines[line - 1]);
}
// remove the section
for (int line = startLine; line < endLine; line++) {
backingLines.removeAt(startLine - 1);
}
// if we want to put a line that was on e.g. 5 on 10 then we have to remove 5...
// (unlike if it was on 10 and we want to put it on 5...it hasn't changed)
if (newLine > startLine) {
newLine -= endLine - startLine;
}
// reinsert
for (int line = 0; line < insert.length; line++) {
backingLines.insert(newLine - 1 + line, insert[line]);
}
renumberFrom(0);
parse();
notifyListeners();
}
addNote(String note) {
addNoteAtLine(cursor.line, note);
}
addNoteAtLine(int line, String note) {
var notetype = "note";
if (note.startsWith("[incomplete]")) {
notetype = "incomplete";
} else if (note.startsWith("[edit]")) {
notetype = "edit";
}
backingLines[line - 1].annotations.add(LineAnnotation(
SourceLink(
path,
lineStart: line,
),
"notes",
AnnotationType.note,
notetype,
description: note));
notifyListeners();
}
int wordCount() {
return backingLines.map((e) => e.text.trim().split(" ").length).fold(0, (previousValue, element) => previousValue + element);
}
void setSuggestIndex(int index) {
suggestionIndex = index;
notifyListeners();
}
Future<bool> spellCheckAll() async {
for (int i = 0; i < backingLines.length; i++) {
await spellcheck(i);
updateStatus(i.toDouble() / backingLines.length.toDouble());
}
return Future.value(true);
}
customDic(BuildContext context, Suggestion suggestion) {
spellchecker!.addToCustomDictionary(suggestion.name);
int line = suggestion.link.lineStart! - 1;
spellcheck(line);
}
correctSpelling(BuildContext context, Suggestion suggestion) {
int start = suggestion.link.colStart!;
int end = suggestion.link.colEnd!;
int line = suggestion.link.lineStart! - 1;
backingLines[line].text = backingLines[line].text.replaceRange(start, end - 1, suggestion.name);
backingLines[line].notifyListeners();
notifyListeners();
spellcheck(line);
}
}
class SpellcheckerRequest {
final String text;
SpellcheckerRequest(this.text);
}