diff --git a/CMakeLists.txt b/CMakeLists.txt index be91d40..72b464a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -237,6 +237,7 @@ set (wsjtx_CXXSRCS jsc.cpp jsc_list.cpp jsc_map.cpp + jsc_checker.cpp SelfDestructMessageBox.cpp messagereplydialog.cpp keyeater.cpp diff --git a/jsc.cpp b/jsc.cpp index 3d0765f..df7f8d3 100644 --- a/jsc.cpp +++ b/jsc.cpp @@ -159,6 +159,13 @@ QString JSC::decompress(Codeword const& bitvec){ return out.join(""); } +bool JSC::exists(QString w, quint32 *pIndex){ + bool found = false; + quint32 index = lookup(w, &found); + if(pIndex) *pIndex = index; + return found && JSC::map[index].size == w.length(); +} + quint32 JSC::lookup(QString w, bool * ok){ if(LOOKUP_CACHE.contains(w)){ if(ok) *ok = true; diff --git a/jsc.h b/jsc.h index 9929e10..b4054d4 100644 --- a/jsc.h +++ b/jsc.h @@ -32,6 +32,7 @@ public: static QList compress(QString text); static QString decompress(Codeword const& bits); + static bool exists(QString w, quint32 *pIndex); static quint32 lookup(QString w, bool *ok); static quint32 lookup(char const* b, bool *ok); diff --git a/jsc_checker.cpp b/jsc_checker.cpp new file mode 100644 index 0000000..727d82b --- /dev/null +++ b/jsc_checker.cpp @@ -0,0 +1,205 @@ +/** + * This file is part of JS8Call. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * (C) 2018 Jordan Sherer - All Rights Reserved + * + **/ + +#include "jsc_checker.h" + +#include +#include +#include +#include +#include +#include + +#include "jsc.h" +#include "varicode.h" + +const int CORRECT = QTextFormat::UserProperty + 10; +const QString ALPHABET = { "ABCDEFGHIJKLMNOPQRSTUVWXYZ" }; + +JSCChecker::JSCChecker(QObject *parent) : + QObject(parent) +{ +} + +bool cursorHasProperty(const QTextCursor &cursor, int property){ + if(property < QTextFormat::UserProperty) { + return false; + } + if(cursor.charFormat().intProperty(property) == 1) { + return true; + } + const QList& formats = cursor.block().layout()->additionalFormats(); + int pos = cursor.positionInBlock(); + foreach(const QTextLayout::FormatRange& range, formats) { + if(pos > range.start && pos <= range.start + range.length && range.format.intProperty(property) == 1) { + return true; + } + } + return false; +} + +QString nextChar(QTextCursor c){ + QTextCursor cur(c); + cur.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor); + return cur.selectedText(); +} + +bool isNumeric(QString s){ + return s.indexOf(QRegExp("^\\d+$")) == 0; +} + +bool isWordChar(QString ch){ + return ch.contains(QRegExp("^\\w$")); +} + +void JSCChecker::checkRange(QTextEdit* edit, int start, int end) +{ + if(end == -1){ + QTextCursor tmpCursor(edit->textCursor()); + tmpCursor.movePosition(QTextCursor::End); + end = tmpCursor.position(); + } + + // stop contentsChange signals from being emitted due to changed charFormats + edit->document()->blockSignals(true); + + qDebug() << "checking range " << start << " - " << end; + + QTextCharFormat errorFmt; + errorFmt.setFontUnderline(true); + errorFmt.setUnderlineColor(Qt::red); + errorFmt.setUnderlineStyle(QTextCharFormat::WaveUnderline); + QTextCharFormat defaultFormat = QTextCharFormat(); + + auto cursor = edit->textCursor(); + + cursor.beginEditBlock(); + { + cursor.setPosition(start); + while(cursor.position() < end) { + bool correct = false; + + cursor.movePosition(QTextCursor::EndOfWord, QTextCursor::KeepAnchor); + if(cursor.selectedText() == "@"){ + cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor); + cursor.movePosition(QTextCursor::EndOfWord, QTextCursor::KeepAnchor); + } + + if(cursorHasProperty(cursor, CORRECT)){ + correct = true; + } else { + QString word = cursor.selectedText(); + + // three or less is always "correct" + if(word.length() < 4 || isNumeric(word)){ + correct = true; + } else { + bool found = false; + quint32 index = JSC::lookup(word, &found); + if(found){ + correct = JSC::map[index].size == word.length(); + } + + if(!correct){ + correct = Varicode::isValidCallsign(word, nullptr); + } + } + } + + if(correct){ + QTextCharFormat fmt = cursor.charFormat(); + fmt.setFontUnderline(defaultFormat.fontUnderline()); + fmt.setUnderlineColor(defaultFormat.underlineColor()); + fmt.setUnderlineStyle(defaultFormat.underlineStyle()); + cursor.setCharFormat(fmt); + } else { + cursor.mergeCharFormat(errorFmt); + } + + // Go to next word start + //while(cursor.position() < end && !isWordChar(nextChar(cursor))){ + // cursor.movePosition(QTextCursor::NextCharacter); + //} + cursor.movePosition(QTextCursor::NextCharacter); + } + } + cursor.endEditBlock(); + + edit->document()->blockSignals(false); +} + +QStringList JSCChecker::suggestions(QString word, int n, bool *pFound){ + QStringList s; + + qDebug() << "computing suggestions for word" << word; + + QMap m; + + bool prefixFound = false; + + // lookup actual word prefix that is not a single character + quint32 index = JSC::lookup(word, &prefixFound); + if(prefixFound){ + auto t = JSC::map[index]; + if(t.size > 1){ + m[index] = QString::fromLatin1(t.str, t.size); + } + } + + // 1-edit distance words (i.e., prefixed/suffixed/edited/removed characters) + for(int i = 0; i < 26; i++){ + auto prefixed = ALPHABET.mid(i, 1) + word; + if(JSC::exists(prefixed, &index)){ + m[index] = prefixed; + } + + auto suffixed = word + ALPHABET.mid(i, 1); + if(JSC::exists(suffixed, &index)){ + m[index] = suffixed; + } + + for(int j = 0; j < word.length(); j++){ + auto edited = word.mid(0, j) + ALPHABET.mid(i, 1) + word.mid(j + 1, word.length() - j); + if(JSC::exists(edited, &index)){ + m[index] = edited; + } + } + } + for(int j = 0; j < word.length(); j++){ + auto deleted = word.mid(0, j) + word.mid(j + 1, word.length() - j); + if(JSC::exists(deleted, &index)){ + m[index] = deleted; + } + } + + // return in order of probability (i.e., index rank) + int i = 0; + foreach(auto key, m.keys()){ + if(i >= n){ + break; + } + s.append(m[key]); + i++; + } + + if(pFound) *pFound = prefixFound; + + return s; +} diff --git a/jsc_checker.h b/jsc_checker.h new file mode 100644 index 0000000..da1d77a --- /dev/null +++ b/jsc_checker.h @@ -0,0 +1,30 @@ +#ifndef JSC_CHECKER_H +#define JSC_CHECKER_H + +/** + * (C) 2018 Jordan Sherer - All Rights Reserved + **/ + +#include + +class QTextEdit; + +class JSCChecker : public QObject +{ + Q_OBJECT +public: + explicit JSCChecker(/*QTextEdit * edit, */ QObject *parent = nullptr); + + static void checkRange(QTextEdit * edit, int start, int end); + static QStringList suggestions(QString word, int n, bool *pFound); + +signals: + +public slots: + //void handleContentsChange(int, int, int); + +private: + //QTextEdit *m_edit; +}; + +#endif // JSC_CHECKER_H diff --git a/mainwindow.cpp b/mainwindow.cpp index 977e140..e19f40c 100644 --- a/mainwindow.cpp +++ b/mainwindow.cpp @@ -64,6 +64,7 @@ #include "messagereplydialog.h" #include "DriftingDateTime.h" #include "jsc.h" +#include "jsc_checker.h" #include "ui_mainwindow.h" #include "moc_mainwindow.cpp" @@ -1108,6 +1109,8 @@ MainWindow::MainWindow(QDir const& temp_directory, bool multiple, ui->spotButton->setChecked(m_config.spot_to_reporting_networks()); + //m_checker = new JSCChecker(ui->extFreeTextMsgEdit, this); + auto enterFilter = new EnterKeyPressEater(); connect(enterFilter, &EnterKeyPressEater::enterKeyPressed, this, [this](QObject *, QKeyEvent *, bool *pProcessed){ if(QApplication::keyboardModifiers() & Qt::ShiftModifier){ @@ -1174,6 +1177,9 @@ MainWindow::MainWindow(QDir const& temp_directory, bool multiple, connect(ui->extFreeTextMsgEdit, &QTableWidget::customContextMenuRequested, this, [this, clearAction2, clearActionAll, restoreAction](QPoint const &point){ QMenu * menu = new QMenu(ui->extFreeTextMsgEdit); + // spelling suggestions... + buildSuggestionsMenu(menu, ui->extFreeTextMsgEdit, point); + auto selectedCall = callsignSelected(); bool missingCallsign = selectedCall.isEmpty(); @@ -5741,10 +5747,18 @@ void MainWindow::on_extFreeTextMsgEdit_currentTextChanged (QString const& text) if(x != text){ int pos = ui->extFreeTextMsgEdit->textCursor().position(); int maxpos = x.size(); - ui->extFreeTextMsgEdit->setPlainText(x); + + // don't emit current text changed or text contents changed + ui->extFreeTextMsgEdit->blockSignals(true); + { + ui->extFreeTextMsgEdit->setPlainText(x); + } + ui->extFreeTextMsgEdit->blockSignals(false); + QTextCursor c = ui->extFreeTextMsgEdit->textCursor(); c.setPosition(pos < maxpos ? pos : maxpos, QTextCursor::MoveAnchor); + // highlight the block with our fonts highlightBlock(c.block(), m_config.compose_text_font(), m_config.color_compose_foreground(), QColor(Qt::transparent)); ui->extFreeTextMsgEdit->setTextCursor(c); @@ -7425,6 +7439,46 @@ QString MainWindow::replaceMacros(QString const &text, QMap va return output; } +void MainWindow::buildSuggestionsMenu(QMenu *menu, QTextEdit *edit, const QPoint &point){ + bool found = false; + + auto c = edit->cursorForPosition(point); + if(c.charFormat().underlineStyle() != QTextCharFormat::WaveUnderline){ + return; + } + + c.movePosition(QTextCursor::StartOfWord); + c.movePosition(QTextCursor::EndOfWord, QTextCursor::KeepAnchor); + + auto word = c.selectedText(); + if(word.isEmpty()){ + return; + } + + QStringList suggestions = JSCChecker::suggestions(word, 10, &found); + if(suggestions.isEmpty() && !found){ + return; + } + + auto suggestionsMenu = menu->addMenu("Suggestions..."); + if(suggestions.isEmpty()){ + auto a = suggestionsMenu->addAction("No suggestions"); + a->setDisabled(true); + } else { + foreach(auto suggestion, suggestions){ + auto a = suggestionsMenu->addAction(suggestion); + + connect(a, &QAction::triggered, this, [this, edit, point, suggestion](){ + auto c = edit->cursorForPosition(point); + c.select(QTextCursor::WordUnderCursor); + c.insertText(suggestion); + }); + } + } + + menu->addSeparator(); +} + void MainWindow::buildSavedMessagesMenu(QMenu *menu){ auto values = buildMacroValues(); @@ -8623,6 +8677,7 @@ void MainWindow::refreshTextDisplay(){ m_txFrameCountEstimate = count; m_txTextDirty = false; + updateTextWordCheckerDisplay(); updateTextStatsDisplay(transmitText, count); updateTxButtonDisplay(); @@ -8661,6 +8716,7 @@ void MainWindow::refreshTextDisplay(){ #endif m_txTextDirty = false; + updateTextWordCheckerDisplay(); updateTextStatsDisplay(transmitText, m_txFrameCountEstimate); updateTxButtonDisplay(); @@ -8669,6 +8725,10 @@ void MainWindow::refreshTextDisplay(){ #endif } +void MainWindow::updateTextWordCheckerDisplay(){ + JSCChecker::checkRange(ui->extFreeTextMsgEdit, 0, -1); +} + void MainWindow::updateTextStatsDisplay(QString text, int count){ if(count > 0){ auto words = text.split(" ", QString::SkipEmptyParts).length(); diff --git a/mainwindow.h b/mainwindow.h index 2311242..5ba7fae 100644 --- a/mainwindow.h +++ b/mainwindow.h @@ -90,6 +90,7 @@ class Detector; class MultiSettings; class EqualizationToolsDialog; class DecodedText; +class JSCChecker; using namespace std; typedef std::function Callback; @@ -298,6 +299,7 @@ private slots: void buildQueryMenu(QMenu *, QString callsign); QMap buildMacroValues(); QString replaceMacros(QString const &text, QMap values, bool prune); + void buildSuggestionsMenu(QMenu *menu, QTextEdit *edit, const QPoint &point); void buildSavedMessagesMenu(QMenu *menu); void buildRelayMenu(QMenu *menu); QAction* buildRelayAction(QString call); @@ -819,6 +821,8 @@ private: QMap>> m_bandActivityCache; // band -> band activity QMap m_rxTextCache; // band -> rx text + JSCChecker * m_checker; + QSet m_callSeenHeartbeat; // call int m_previousFreq; bool m_shouldRestoreFreq; @@ -915,6 +919,7 @@ private: void updateRepeatButtonDisplay(); void updateTextDisplay(); void updateFrameCountEstimate(int count); + void updateTextWordCheckerDisplay(); void updateTextStatsDisplay(QString text, int count); void updateTxButtonDisplay(); bool isMyCallIncluded(QString const &text); diff --git a/wsjtx.pro b/wsjtx.pro index dfda8d2..dcfe010 100644 --- a/wsjtx.pro +++ b/wsjtx.pro @@ -77,7 +77,8 @@ SOURCES += \ DriftingDateTime.cpp \ jsc.cpp \ jsc_list.cpp \ - jsc_map.cpp + jsc_map.cpp \ + jsc_checker.cpp HEADERS += qt_helpers.hpp \ pimpl_h.hpp pimpl_impl.hpp \ @@ -105,7 +106,8 @@ HEADERS += qt_helpers.hpp \ messagereplydialog.h \ keyeater.h \ DriftingDateTime.h \ - jsc.h + jsc.h \ + jsc_checker.h INCLUDEPATH += qmake_only