1 //===========================================
2 //  Lumina-DE source code
3 //  Copyright (c) 2015, Ken Moore
4 //  Available under the 3-clause BSD license
5 //  See the LICENSE file for full details
6 //===========================================
7 #include "PlainTextEditor.h"
8 
9 #include <QColor>
10 #include <QPainter>
11 #include <QTextBlock>
12 #include <QFileDialog>
13 #include <QDebug>
14 #include <QApplication>
15 #include <QMessageBox>
16 #include <QMenu>
17 #include <QClipboard>
18 #include <QTimer>
19 
20 #include <LUtils.h>
21 
22 //==============
23 //       PUBLIC
24 //==============
PlainTextEditor(QSettings * set,QWidget * parent)25 PlainTextEditor::PlainTextEditor(QSettings *set, QWidget *parent) : QPlainTextEdit(parent){
26   settings = set;
27   LNW = new LNWidget(this);
28   showLNW = true;
29   watcher = new QFileSystemWatcher(this);
30   hasChanges = readonly = false;
31   lastSaveContents.clear();
32   matchleft = matchright = -1;
33   this->setTabStopDistance( 8 * QFontMetricsF(this->font()).width(' ') ); //8 character spaces per tab (UNIX standard)
34   //this->setObjectName("PlainTextEditor");
35   //this->setStyleSheet("QPlainTextEdit#PlainTextEditor{ }");
36   SYNTAX = new Custom_Syntax(settings, this->document());
37   connect(this, SIGNAL(blockCountChanged(int)), this, SLOT(LNW_updateWidth()) );
38   connect(this, SIGNAL(cursorPositionChanged()), this, SLOT(LNW_highlightLine()) );
39   connect(this, SIGNAL(updateRequest(const QRect&, int)), this, SLOT(LNW_update(const QRect&, int)) );
40   connect(this, SIGNAL(cursorPositionChanged()), this, SLOT(checkMatchChar()) );
41   connect(this, SIGNAL(cursorPositionChanged()), this, SLOT(cursorMoved()) );
42   connect(this, SIGNAL(textChanged()), this, SLOT(textChanged()) );
43   connect(watcher, SIGNAL(fileChanged(const QString&)), this, SLOT(fileChanged()) );
44   LNW_updateWidth();
45   LNW_highlightLine();
46 }
47 
~PlainTextEditor()48 PlainTextEditor::~PlainTextEditor(){
49 
50 }
51 
showLineNumbers(bool show)52 void PlainTextEditor::showLineNumbers(bool show){
53   showLNW = show;
54   LNW->setVisible(show);
55   LNW_updateWidth();
56 }
57 
LoadSyntaxRule(QString type)58 void PlainTextEditor::LoadSyntaxRule(QString type){
59   QList<SyntaxFile> files = SyntaxFile::availableFiles(settings);
60   for(int i=0; i<files.length(); i++){
61     if(files[i].name() == type){
62       files[i].SetupDocument(this);
63       SYNTAX->loadRules(files[i]);
64       break;
65     }else if(i==files.length()-1){
66       SyntaxFile dummy;
67       SYNTAX->loadRules(dummy);
68     }
69   }
70   SYNTAX->rehighlight();
71 }
72 
updateSyntaxColors()73 void PlainTextEditor::updateSyntaxColors(){
74   SYNTAX->reloadRules();
75   SYNTAX->rehighlight();
76 }
77 
78 //File loading/setting options
LoadFile(QString filepath)79 void PlainTextEditor::LoadFile(QString filepath){
80   if( !watcher->files().isEmpty() ){  watcher->removePaths(watcher->files()); }
81   bool diffFile = (filepath != this->whatsThis());
82   this->setWhatsThis(filepath);
83   this->clear();
84   /*QList<SyntaxFile> files = SyntaxFile::availableFiles(settings);
85   for(int i=0; i<files.length(); i++){
86     if(files[i].supportsFile(filepath) ){
87       files[i].SetupDocument(this);
88       SYNTAX->loadRules(files[i]);
89       break;
90     }
91   }*/
92   //SYNTAX->loadRules( Custom_Syntax::ruleForFile(filepath.section("/",-1), settings) );
93   lastSaveContents = LUtils::readFile(filepath).join("\n");
94   if(diffFile){
95     SYNTAX->loadRules( Custom_Syntax::ruleForFile(this->whatsThis().section("/",-1), settings) );
96     if(SYNTAX->loadedRules().isEmpty()){
97       SYNTAX->loadRules( Custom_Syntax::ruleForFirstLine( lastSaveContents.section("\n",0,0,QString::SectionSkipEmpty) , settings) );
98     }
99     SYNTAX->setupDocument(this);
100     this->setPlainText( lastSaveContents );
101   }else{
102     //Try to keep the mouse cursor/scroll in the same position
103     int curpos = this->textCursor().position();;
104     this->setPlainText( lastSaveContents );
105     QApplication::processEvents();
106     QTextCursor cur = this->textCursor();
107       cur.setPosition(curpos);
108     this->setTextCursor( cur );
109     this->centerCursor(); //scroll until cursor is centered (if possible)
110   }
111   hasChanges = false;
112   readonly = false;
113   if(QFile::exists(filepath)){
114     readonly =  !QFileInfo(filepath).isWritable();
115     watcher->addPath(filepath);
116   }else if(filepath.startsWith("/")){
117     //See if the containing directory is writable instead
118     readonly =  !QFileInfo(filepath.section("/",0,-2)).isWritable();
119   }
120   emit FileLoaded(this->whatsThis());
121 }
122 
SaveFile(bool newname)123 bool PlainTextEditor::SaveFile(bool newname){
124   //NOTE: This returns true for proper behaviour, and false for a user-cancelled process
125   //qDebug() << "Save File:" << this->whatsThis();
126   //Quick check for a non-editable file
127   if(!newname && this->whatsThis().startsWith("/")){
128     if(!QFileInfo(this->whatsThis()).isWritable()){ newname = true; } //cannot save the current file name/location
129   }
130   if( !this->whatsThis().startsWith("/") || newname ){
131     //prompt for a filename/path
132     QString file = QFileDialog::getSaveFileName(this, tr("Save File"), this->whatsThis(), tr("Text File (*)"));
133     if(file.isEmpty()){ return false; } //cancelled
134     this->setWhatsThis(file);
135     SYNTAX->loadRules( Custom_Syntax::ruleForFile(this->whatsThis().section("/",-1), settings) );
136     if(SYNTAX->loadedRules().isEmpty()){
137       SYNTAX->loadRules( Custom_Syntax::ruleForFirstLine( this->toPlainText().section("\n",0,0,QString::SectionSkipEmpty) , settings) );
138     }
139     SYNTAX->setupDocument(this);
140     SYNTAX->rehighlight();
141   }
142   if( !watcher->files().isEmpty() ){ watcher->removePaths(watcher->files()); }
143   bool ok = LUtils::writeFile(this->whatsThis(), this->toPlainText().split("\n"), true);
144   hasChanges = !ok;
145   if(ok){ lastSaveContents = this->toPlainText(); emit FileLoaded(this->whatsThis()); }
146   watcher->addPath(currentFile());
147   readonly = !QFileInfo(this->whatsThis()).isWritable(); //update this flag
148   return true;
149   //qDebug() << " - Success:" << ok << hasChanges;
150 }
151 
currentFile()152 QString PlainTextEditor::currentFile(){
153   return this->whatsThis();
154 }
155 
hasChange()156 bool PlainTextEditor::hasChange(){
157   return hasChanges;
158 }
159 
readOnlyFile()160 bool PlainTextEditor::readOnlyFile(){
161   //qDebug() << "Read Only File:" << readonly << this->whatsThis();
162   return readonly;
163 }
164 
165 //Functions for managing the line number widget
LNWWidth()166 int PlainTextEditor::LNWWidth(){
167   //Get the number of chars we need for line numbers
168   int lines = this->blockCount();
169   if(lines<1){ lines = 1; }
170   int chars = 1;
171   //qDebug() << "point 1" << this->document()->defaultFont();
172   while(lines>=10){ chars++; lines/=10; }
173   QFontMetrics metrics(this->document()->defaultFont());
174   return (metrics.horizontalAdvance("9")*chars); //make sure to add a tiny bit of padding
175 }
176 
paintLNW(QPaintEvent * ev)177 void PlainTextEditor::paintLNW(QPaintEvent *ev){
178   //qDebug() << "Paint LNW Event:" << ev->rect() << LNW->geometry();
179   //if(ev->rect().height() < (QFontMetrics(this->document()->defaultFont()).height() *1.5) ){ return; }
180   //qDebug() << " -- paint line numbers";
181   QPainter P(LNW);
182   //First set the background color
183   P.fillRect(ev->rect(), QColor("lightgrey"));
184   //Now determine which line numbers to show (based on the current viewport)
185   QTextBlock block = this->firstVisibleBlock();
186   int bTop = blockBoundingGeometry(block).translated(contentOffset()).top();
187   int bBottom;
188 //  QFont font = P.font();
189 //  font.setPointSize(this->document()->defaultFont().pointSize());
190   P.setFont(this->document()->defaultFont());
191   //Now loop over the blocks (lines) and write in the numbers
192   QFontMetrics metrics(this->document()->defaultFont());
193   //qDebug() << "point 2" << this->document()->defaultFont();
194   P.setPen(Qt::black); //setup the font color
195   while(block.isValid() && bTop<=ev->rect().bottom()){ //ensure block below top of viewport
196     bBottom = bTop+blockBoundingRect(block).height();
197     if(block.isVisible() && bBottom >= ev->rect().top()){ //ensure block above bottom of viewport
198       P.drawText(0,bTop, LNW->width(), metrics.height(), Qt::AlignRight, QString::number(block.blockNumber()+1) );
199       //qDebug() << "bTop" << bTop;
200       //qDebug() << "LNW->width()" << LNW->width();
201       //qDebug() << "metrics.height()" << metrics.height();
202     }
203     //Go to the next block
204     block = block.next();
205     bTop = bBottom;
206   }
207 }
208 
209 //==============
210 //       PRIVATE
211 //==============
clearMatchData()212 void PlainTextEditor::clearMatchData(){
213   if(matchleft>=0 || matchright>=0){
214     QList<QTextEdit::ExtraSelection> sel = this->extraSelections();
215     for(int i=0; i<sel.length(); i++){
216       if(sel[i].cursor.selectedText().length()==1){ sel.takeAt(i); i--; }
217     }
218     this->setExtraSelections(sel);
219     matchleft = -1;
220     matchright = -1;
221   }
222 }
223 
highlightMatch(QChar ch,bool forward,int fromPos,QChar startch)224 void PlainTextEditor::highlightMatch(QChar ch, bool forward, int fromPos, QChar startch){
225   if(forward){ matchleft = fromPos;  }
226   else{ matchright = fromPos; }
227 
228   int nested = 1; //always start within the first nest (the primary nest)
229   int tmpFromPos = fromPos;
230   //if(!forward){ tmpFromPos++; } //need to include the initial location
231   QString doc = this->toPlainText();
232   while( nested>0 && tmpFromPos<doc.length() && ( (tmpFromPos>=fromPos && forward) || ( tmpFromPos<=fromPos && !forward ) ) ){
233     if(forward){
234       QTextCursor cur = this->document()->find(ch, tmpFromPos);
235       if(!cur.isNull()){
236 	nested += doc.mid(tmpFromPos+1, cur.position()-tmpFromPos).count(startch) -1;
237 	if(nested==0){ matchright = cur.position(); }
238 	else{ tmpFromPos = cur.position(); }
239       }else{ break; }
240     }else{
241       QTextCursor cur = this->document()->find(ch, tmpFromPos, QTextDocument::FindBackward);
242       if(!cur.isNull()){
243         QString mid = doc.mid(cur.position()-1, tmpFromPos-cur.position()+1);
244         //qDebug() << "Found backwards match:" << nested << startch << ch << mid;
245         //qDebug() << doc.mid(cur.position(),1) << doc.mid(tmpFromPos,1);
246 	nested += (mid.count(startch) - mid.count(ch));
247 	if(nested==0){ matchleft = cur.position(); }
248 	else{ tmpFromPos = cur.position()-1; }
249       }else{ break; }
250     }
251   }
252 
253   //Now highlight the two characters
254   QList<QTextEdit::ExtraSelection> sels = this->extraSelections();
255   if(matchleft>=0){
256     QTextEdit::ExtraSelection sel;
257     if(matchright>=0){ sel.format.setBackground( QColor(settings->value("colors/bracket-found").toString()) ); }
258     else{ sel.format.setBackground( QColor(settings->value("colors/bracket-missing").toString()) ); }
259     QTextCursor cur = this->textCursor();
260       cur.setPosition(matchleft);
261       if(forward){ cur.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor); }
262       else{ cur.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor); }
263     sel.cursor = cur;
264     sels << sel;
265   }
266   if(matchright>=0){
267     QTextEdit::ExtraSelection sel;
268     if(matchleft>=0){ sel.format.setBackground( QColor(settings->value("colors/bracket-found").toString()) ); }
269     else{ sel.format.setBackground( QColor(settings->value("colors/bracket-missing").toString()) ); }
270     QTextCursor cur = this->textCursor();
271       cur.setPosition(matchright);
272       if(!forward){ cur.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor); }
273       else{ cur.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor); }
274     sel.cursor = cur;
275     sels << sel;
276   }
277   this->setExtraSelections(sels);
278 }
279 
280 //===================
281 //       PRIVATE SLOTS
282 //===================
283 //Functions for managing the line number widget
LNW_updateWidth()284 void PlainTextEditor::LNW_updateWidth(){
285   if(showLNW){
286     this->setViewportMargins( LNWWidth(), 0, 0, 0); //the LNW is contained within the left margin
287   }else{
288     this->setViewportMargins( 0, 0, 0, 0); //the LNW is contained within the left margin
289   }
290 }
291 
LNW_highlightLine()292 void PlainTextEditor::LNW_highlightLine(){
293   QList<QTextEdit::ExtraSelection> sels;
294   foreach(Word *word, wordList) { sels.append(word->sel); };
295   if(this->isReadOnly()){ return; }
296   QColor highC = QColor(0,0,0,50); //just darken the line a bit
297   QTextEdit::ExtraSelection sel;
298   sel.format.setBackground(highC);
299   sel.format.setProperty(QTextFormat::FullWidthSelection, true);
300   sel.cursor = this->textCursor();
301   sel.cursor.clearSelection(); //just in case it already has one
302   setExtraSelections( sels << sel);
303 }
304 
LNW_update(const QRect & rect,int dy)305 void PlainTextEditor::LNW_update(const QRect &rect, int dy){
306   if(dy!=0){ LNW->scroll(0,dy); } //make sure to scroll the line widget the same amount as the editor
307   else{
308     //Some other reason we need to repaint the widget
309     LNW->update(0,rect.y(), LNW->width(), rect.height()); //also repaint the LNW in the same area
310   }
311   if(rect.contains(this->viewport()->rect())){
312     //Something in the currently-viewed area needs updating - make sure the LNW width is still correct
313     LNW_updateWidth();
314   }
315 }
316 
317 //Function for running the matching routine
checkMatchChar()318 void PlainTextEditor::checkMatchChar(){
319   clearMatchData();
320   int pos = this->textCursor().position();
321   QChar ch = this->document()->characterAt(pos);
322   bool tryback = true;
323   while(tryback){
324     tryback = false;
325     if(ch==QChar('(')){ highlightMatch(QChar(')'),true, pos, QChar('(') ); }
326     else if(ch==QChar(')')){ highlightMatch(QChar('('),false, pos, QChar(')') ); }
327     else if(ch==QChar('{')){ highlightMatch(QChar('}'),true, pos, QChar('{') ); }
328     else if(ch==QChar('}')){ highlightMatch(QChar('{'),false, pos, QChar('}') ); }
329     else if(ch==QChar('[')){ highlightMatch(QChar(']'),true, pos, QChar('[') ); }
330     else if(ch==QChar(']')){ highlightMatch(QChar('['),false, pos, QChar(']') ); }
331     else if(pos==this->textCursor().position()){
332       //Try this one more time - using the previous character instead of the current character
333       tryback = true;
334       pos--;
335       ch = this->document()->characterAt(pos);
336     }
337   } //end check for next/previous char
338 }
339 
340 //Functions for notifying the parent widget of changes
textChanged()341 void PlainTextEditor::textChanged(){
342   //qDebug() << " - Got Text Changed signal";
343   bool changed = (lastSaveContents != this->toPlainText());
344   emit CheckSpelling(this->textCursor().position(), -1);
345   if(changed == hasChanges){ return; } //no change
346   hasChanges = changed; //save for reading later
347   if(hasChanges){  emit UnsavedChanges( this->whatsThis() ); }
348   else{ emit FileLoaded(this->whatsThis()); }
349 }
350 
cursorMoved()351 void PlainTextEditor::cursorMoved(){
352   //Update the status tip for the editor to show the row/column number for the cursor
353   QTextCursor cur = this->textCursor();
354   QString stat = tr("Row Number: %1, Column Number: %2");
355   this->setStatusTip(stat.arg(QString::number(cur.blockNumber()+1) , QString::number(cur.columnNumber()) ) );
356   emit statusTipChanged();
357 }
358 
359 //Function for prompting the user if the file changed externally
fileChanged()360 void PlainTextEditor::fileChanged(){
361   qDebug() << "File Changed:" << currentFile();
362   bool update = !hasChanges; //Go ahead and reload the file automatically - no custom changes in the editor
363   QString text = tr("The following file has been changed by some other utility. Do you want to re-load it?");
364   text.append("\n");
365   text.append( tr("(Note: You will lose all currently-unsaved changes)") );
366   text.append("\n\n%1");
367 
368   if(!update){
369     update = (QMessageBox::Yes == QMessageBox::question(this, tr("File Modified"),text.arg(currentFile()) , QMessageBox::Yes | QMessageBox::No, QMessageBox::No) );
370   }
371   //Now update the text in the editor as needed
372   if(update){
373     LoadFile( currentFile() );
374   }
375 }
376 
377 //==================
378 //       PROTECTED
379 //==================
resizeEvent(QResizeEvent * ev)380 void PlainTextEditor::resizeEvent(QResizeEvent *ev){
381   QPlainTextEdit::resizeEvent(ev); //do the normal resize processing
382   //Now re-adjust the placement of the LNW (within the left margin area)
383   QRect cGeom = this->contentsRect();
384   LNW->setGeometry( QRect(cGeom.left(), cGeom.top(), LNWWidth(), cGeom.height()) );
385 }
386 
updateLNW()387 void PlainTextEditor::updateLNW(){
388     LNW_updateWidth();
389 }
390 
wordAtPosition(int blockNum,int pos)391 Word *PlainTextEditor::wordAtPosition(int blockNum, int pos) {
392   foreach(Word *word, wordList) {
393     //qDebug() << word->word << "WordBlock:" << word->blockNum << "Block:" << blockNum << "WordPos:" << word->position << "Pos:" << pos;
394     if(word->blockNum == blockNum and pos >= word->position and pos <= word->position+word->word.length())
395       return word;
396   }
397   return NULL;
398 }
399 
contextMenuEvent(QContextMenuEvent * ev)400 void PlainTextEditor::contextMenuEvent(QContextMenuEvent *ev){
401     QMenu *menu = createStandardContextMenu();
402     /*qDebug() << this->textCursor().blockNumber() << this->textCursor().positionInBlock();
403     Word *word = wordAtPosition(this->textCursor().blockNumber(), this->textCursor().positionInBlock());
404     QList<QAction*> suggestionList;
405     if(word != NULL) {
406       foreach(QString word, word->suggestions) {
407         QAction *suggestionAction = menu->addAction(word);
408         connect(suggestionAction, &QAction::triggered, this, [=]() { Something });
409         suggestionList.append(suggestionAction);
410       }
411       menu->addSeparator();
412       QAction *ignore = menu->addAction(tr("Ignore"));
413       connect(ignore, &QAction::triggered, this, [word]() { word->ignore(); });
414       QAction *ignoreAll = menu->addAction(tr("Ignore All"));
415       connect(ignoreAll, &QAction::triggered, this, [=]() { foreach(Word *wordP, wordList) { if(wordP->word == word->word) { wordP->ignore(); }} });
416       QAction *addToDictionary = menu->addAction(tr("Add to Dictionary"));
417       connect(addToDictionary, &QAction::triggered, this, [word, this]() { hunspell->add(word->word.toStdString()); });
418     }*/
419     menu->exec(ev->globalPos());
420     delete menu;
421 }
422 
keyPressEvent(QKeyEvent * ev)423 void PlainTextEditor::keyPressEvent(QKeyEvent *ev) {
424   //Check spelling when copy/paste
425   if(ev->matches(QKeySequence::Paste) or ev->matches(QKeySequence::Cut)) {
426     QClipboard *clipboard = QGuiApplication::clipboard();
427     int epos = this->textCursor().position() + clipboard->text().size();
428     //qDebug() << this->textCursor().position() << epos;
429     QTimer::singleShot(100, this, [=]() { emit CheckSpelling(this->textCursor().position(), epos); });
430   }
431 
432   QPlainTextEdit::keyPressEvent(ev);
433 }
434