1 /*!********************************************************************
2 *
3  Audacity: A Digital Audio Editor
4 
5  TextEditHelper.h
6 
7  Vitaly Sverchinsky
8 
9  The major part of the logic is extracted from LabelTrackView.cpp and
10  LabelTextHandle.cpp
11 
12  **********************************************************************/
13 
14 #include "TextEditHelper.h"
15 
16 #include <wx/app.h>
17 #include <wx/dc.h>
18 #include <wx/dcmemory.h>
19 #include <wx/clipbrd.h>
20 
21 #include "../../ProjectWindow.h"
22 #include "../../RefreshCode.h"
23 
24 TextEditDelegate::~TextEditDelegate() = default;
25 
IsGoodEditKeyCode(int keyCode)26 bool TextEditHelper::IsGoodEditKeyCode(int keyCode)
27 {
28     // Accept everything outside of WXK_START through WXK_COMMAND, plus the keys
29    // within that range that are usually printable, plus the ones we use for
30    // keyboard navigation.
31     return keyCode < WXK_START ||
32         (keyCode >= WXK_END && keyCode < WXK_UP) ||
33         (keyCode == WXK_RIGHT) ||
34         (keyCode >= WXK_NUMPAD0 && keyCode <= WXK_DIVIDE) ||
35         (keyCode >= WXK_NUMPAD_SPACE && keyCode <= WXK_NUMPAD_ENTER) ||
36         (keyCode >= WXK_NUMPAD_HOME && keyCode <= WXK_NUMPAD_END) ||
37         (keyCode >= WXK_NUMPAD_DELETE && keyCode <= WXK_NUMPAD_DIVIDE) ||
38 #if defined(__WXMAC__)
39         (keyCode > WXK_RAW_CONTROL) ||
40 #endif
41         (keyCode > WXK_WINDOWS_MENU);
42 }
43 
TextEditHelper(const std::weak_ptr<TextEditDelegate> & delegate,const wxString & text,const wxFont & font)44 TextEditHelper::TextEditHelper(const std::weak_ptr<TextEditDelegate>& delegate, const wxString& text, const wxFont& font)
45     : mFont(font),
46     mDelegate(delegate),
47     mText(text),
48     mInitialCursorPos(0)
49 {
50     mCurrentCursorPos = text.Length();
51 }
52 
SetTextColor(const wxColor & textColor)53 void TextEditHelper::SetTextColor(const wxColor& textColor)
54 {
55     mTextColor = textColor;
56 }
57 
SetTextSelectionColor(const wxColor & textSelectionColor)58 void TextEditHelper::SetTextSelectionColor(const wxColor& textSelectionColor)
59 {
60     mTextSelectionColor = textSelectionColor;
61 }
62 
Cancel(AudacityProject * project)63 void TextEditHelper::Cancel(AudacityProject* project)
64 {
65     if (auto lock = mDelegate.lock())
66         lock->OnTextEditCancelled(project);
67 }
68 
Finish(AudacityProject * project)69 void TextEditHelper::Finish(AudacityProject* project)
70 {
71     if (auto lock = mDelegate.lock())
72         lock->OnTextEditFinished(project, mText);
73 }
74 
GetSelection() const75 std::pair<int, int> TextEditHelper::GetSelection() const
76 {
77     return std::make_pair(mInitialCursorPos, mCurrentCursorPos);
78 }
79 
SetSelection(int from,int to)80 void TextEditHelper::SetSelection(int from, int to)
81 {
82     mInitialCursorPos = from;
83     mCurrentCursorPos = to;
84 }
85 
SelectAll()86 void TextEditHelper::SelectAll()
87 {
88     mInitialCursorPos = 0;
89     mCurrentCursorPos = mText.Length();
90 }
91 
IsSelectionEmpty()92 bool TextEditHelper::IsSelectionEmpty()
93 {
94     return mCurrentCursorPos == mInitialCursorPos;
95 }
96 
CaptureKey(int,int mods)97 bool TextEditHelper::CaptureKey(int, int mods)
98 {
99    return mods == wxMOD_NONE || mods == wxMOD_SHIFT;
100 }
101 
OnKeyDown(int keyCode,int mods,AudacityProject * project)102 bool TextEditHelper::OnKeyDown(int keyCode, int mods, AudacityProject* project)
103 {
104     auto delegate = mDelegate.lock();
105     if (!delegate)
106         return false;
107 
108     if (!CaptureKey(keyCode, mods))
109        return false;
110 
111     wxUniChar wchar;
112     bool more = true;
113 
114     switch (keyCode) {
115 
116     case WXK_BACK:
117     {
118         //IF the label is not blank THEN get rid of a letter or letters according to cursor position
119         if (!mText.empty())
120         {
121             // IF there are some highlighted letters, THEN DELETE them
122             if (mInitialCursorPos != mCurrentCursorPos)
123                 RemoveSelectedText(project);
124             else
125             {
126                 // DELETE one codepoint leftwards
127                 while ((mCurrentCursorPos > 0) && more) {
128                     wchar = mText.at(mCurrentCursorPos - 1);
129                     mText.erase(mCurrentCursorPos - 1, 1);
130                     mCurrentCursorPos--;
131                     if (((int)wchar > 0xDFFF) || ((int)wchar < 0xDC00))
132                     {
133                         delegate->OnTextModified(project, mText);
134                         more = false;
135                     }
136                 }
137             }
138             mInitialCursorPos = mCurrentCursorPos;
139             mOffset = std::clamp(mOffset, 0, std::max(0, static_cast<int>(mText.Length()) - 1));
140             return true;
141         }
142     }
143     break;
144 
145     case WXK_DELETE:
146     case WXK_NUMPAD_DELETE:
147     {
148         int len = mText.length();
149         //If the label is not blank get rid of a letter according to cursor position
150         if (len > 0)
151         {
152             // if there are some highlighted letters, DELETE them
153             if (mInitialCursorPos != mCurrentCursorPos)
154                 RemoveSelectedText(project);
155             else
156             {
157                 // DELETE one codepoint rightwards
158                 while ((mCurrentCursorPos < len) && more) {
159                     wchar = mText.at(mCurrentCursorPos);
160                     mText.erase(mCurrentCursorPos, 1);
161                     if (((int)wchar > 0xDBFF) || ((int)wchar < 0xD800))
162                     {
163                         delegate->OnTextModified(project, mText);
164                         more = false;
165                     }
166                 }
167             }
168             mInitialCursorPos = mCurrentCursorPos;
169             mOffset = std::clamp(mOffset, 0, std::max(0, static_cast<int>(mText.Length()) - 1));
170             return true;
171         }
172     }
173     break;
174 
175     case WXK_HOME:
176     case WXK_NUMPAD_HOME:
177         // Move cursor to beginning of label
178         mCurrentCursorPos = 0;
179         if (mods == wxMOD_SHIFT)
180             ;
181         else
182             mInitialCursorPos = mCurrentCursorPos;
183         return true;
184     case WXK_END:
185     case WXK_NUMPAD_END:
186         // Move cursor to end of label
187         mCurrentCursorPos = (int)mText.length();
188         if (mods == wxMOD_SHIFT)
189             ;
190         else
191             mInitialCursorPos = mCurrentCursorPos;
192         return true;
193 
194     case WXK_LEFT:
195     case WXK_NUMPAD_LEFT:
196         // Moving cursor left
197        if (mods != wxMOD_SHIFT && mCurrentCursorPos != mInitialCursorPos)
198           //put cursor to the left edge of selection
199           mInitialCursorPos = mCurrentCursorPos =
200           std::min(mInitialCursorPos, mCurrentCursorPos);
201        else
202        {
203           while ((mCurrentCursorPos > 0) && more) {
204              wchar = mText.at(mCurrentCursorPos - 1);
205              more = !(((int)wchar > 0xDFFF) || ((int)wchar < 0xDC00));
206 
207              --mCurrentCursorPos;
208           }
209           if (mods != wxMOD_SHIFT)
210              mInitialCursorPos = mCurrentCursorPos;
211        }
212        return true;
213 
214     case WXK_RIGHT:
215     case WXK_NUMPAD_RIGHT:
216        // Moving cursor right
217        if (mods != wxMOD_SHIFT && mCurrentCursorPos != mInitialCursorPos)
218           //put cursor to the right edge of selection
219           mInitialCursorPos = mCurrentCursorPos =
220           std::max(mInitialCursorPos, mCurrentCursorPos);
221        else
222        {
223           while ((mCurrentCursorPos < (int)mText.length()) && more) {
224              wchar = mText.at(mCurrentCursorPos);
225              more = !(((int)wchar > 0xDBFF) || ((int)wchar < 0xD800));
226 
227              ++mCurrentCursorPos;
228           }
229           if (mods != wxMOD_SHIFT)
230              mInitialCursorPos = mCurrentCursorPos;
231        }
232 
233        return true;
234 
235     case WXK_ESCAPE:
236         delegate->OnTextEditCancelled(project);
237         return true;
238     case WXK_RETURN:
239     case WXK_NUMPAD_ENTER:
240     case WXK_TAB:
241         delegate->OnTextEditFinished(project, mText);
242         return true;
243     }
244     return false;
245 }
246 
OnChar(int charCode,AudacityProject * project)247 bool TextEditHelper::OnChar(int charCode, AudacityProject* project)
248 {
249     auto delegate = mDelegate.lock();
250     if (!delegate)
251         return false;
252 
253     if (charCode == 0 || wxIscntrl(charCode)) {
254         return false;
255     }
256 
257     // Test if cursor is in the end of string or not
258     if (mInitialCursorPos != mCurrentCursorPos)
259         RemoveSelectedText(project);
260 
261     if (mCurrentCursorPos < (int)mText.length()) {
262         // Get substring on the righthand side of cursor
263         wxString rightPart = mText.Mid(mCurrentCursorPos);
264         // Set title to substring on the lefthand side of cursor
265         mText = mText.Left(mCurrentCursorPos);
266         //append charcode
267         mText += charCode;
268         //append the right part substring
269         mText += rightPart;
270     }
271     else
272         //append charCode
273         mText += charCode;
274 
275     delegate->OnTextModified(project, mText);
276 
277     //moving cursor position forward
278     mInitialCursorPos = ++mCurrentCursorPos;
279 
280     return true;
281 }
282 
OnClick(const wxMouseEvent & event,AudacityProject *)283 bool TextEditHelper::OnClick(const wxMouseEvent& event, AudacityProject*)
284 {
285     if (event.ButtonDown())
286     {
287         bool result = false;
288         if (mBBox.Contains(event.GetPosition()))
289         {
290             if (event.LeftDown())
291             {
292                 mRightDragging = false;
293                 auto position = FindCursorIndex(event.GetPosition());
294                 auto initial = mInitialCursorPos;
295                 if (event.ShiftDown()) {
296 #ifdef __WXMAC__
297                     // Set the drag anchor at the end of the previous selection
298                     // that is farther from the NEW drag end
299                     const auto current = mCurrentCursorPos;
300                     if (abs(position - current) > abs(position - initial))
301                         initial = current;
302 #else
303                     // initial position remains as before
304 #endif
305                 }
306                 else
307                     initial = position;
308 
309                 mInitialCursorPos = initial;
310                 mCurrentCursorPos = position;
311             }
312             else
313             {
314                 if (mInitialCursorPos == mCurrentCursorPos)
315                 {
316                     auto position = FindCursorIndex(event.GetPosition());
317                     mInitialCursorPos = mCurrentCursorPos = position;
318                 }
319                 // Actually this might be right or middle down
320                 mRightDragging = true;
321             }
322             result = true;
323         }
324 #if defined(__WXGTK__) && (HAVE_GTK)
325         if (evt.MiddleDown()) {
326             // Paste text, making a NEW label if none is selected.
327             wxTheClipboard->UsePrimarySelection(true);
328             view.PasteSelectedText(project, newSel.t0(), newSel.t1());
329             wxTheClipboard->UsePrimarySelection(false);
330             result = true;
331         }
332 #endif
333         return result;
334     }
335     return false;
336 }
337 
OnDrag(const wxMouseEvent & event,AudacityProject * project)338 bool TextEditHelper::OnDrag(const wxMouseEvent& event, AudacityProject* project)
339 {
340     return HandleDragRelease(event, project);
341 }
342 
OnRelease(const wxMouseEvent & event,AudacityProject * project)343 bool TextEditHelper::OnRelease(const wxMouseEvent& event, AudacityProject* project)
344 {
345     return HandleDragRelease(event, project);
346 }
347 
Draw(wxDC & dc,const wxRect & rect)348 void TextEditHelper::Draw(wxDC& dc, const wxRect& rect)
349 {
350     mBBox = rect;
351     dc.SetFont(mFont);
352 
353     const auto cursorHeight = dc.GetFontMetrics().height;
354 
355     wxDCClipper clipper(dc, rect);
356 
357     auto rtl = wxTheApp->GetLayoutDirection() == wxLayout_RightToLeft;
358 
359     auto curPosX = 0;
360     auto maxOffset = static_cast<int>(mText.Length());
361     mOffset = std::clamp(mOffset, 0, maxOffset);
362     {
363         auto leftBound = rect.GetLeft();
364         auto rightBound = rect.GetRight() + 1;
365         GetCharPositionX(mCurrentCursorPos, &curPosX);
366 
367         if ((!rtl && curPosX >= rightBound) || (rtl && curPosX < leftBound))
368         {
369             while (mOffset < maxOffset)
370             {
371                 GetCharPositionX(mCurrentCursorPos, &curPosX);
372                 if (curPosX < rightBound && curPosX >= leftBound)
373                     break;
374                 ++mOffset;
375             }
376         }
377         if ((!rtl && curPosX < leftBound) || (rtl && curPosX >= rightBound))
378         {
379             while (mOffset > 0)
380             {
381                 GetCharPositionX(mCurrentCursorPos, &curPosX);
382                 if (curPosX >= leftBound && curPosX < rightBound)
383                     break;
384                 --mOffset;
385             }
386         }
387     }
388 
389     if (mCurrentCursorPos != mInitialCursorPos)
390     {
391         auto left = 0;
392         auto right = 0;
393         GetCharPositionX(std::min(mCurrentCursorPos, mInitialCursorPos), &left);
394         GetCharPositionX(std::max(mCurrentCursorPos, mInitialCursorPos), &right);
395         dc.SetPen(*wxTRANSPARENT_PEN);
396         dc.SetBrush(mTextSelectionColor);
397         dc.DrawRectangle(wxRect(left, rect.GetTop() + (rect.GetHeight() - cursorHeight) / 2, right - left, cursorHeight));
398     }
399 
400     dc.SetTextBackground(wxTransparentColour);
401     dc.SetTextForeground(mTextColor);
402     dc.SetFont(wxFont(wxFontInfo()));
403     dc.DrawLabel(mText.Mid(mOffset), rect, (rtl ? wxALIGN_RIGHT : wxALIGN_LEFT) | wxALIGN_CENTER_VERTICAL);
404 
405     if (mCurrentCursorPos == mInitialCursorPos)
406     {
407         dc.SetPen(mTextColor);
408         auto top = rect.GetTop() + (rect.GetHeight() - cursorHeight) / 2;
409         dc.DrawLine(curPosX, top, curPosX, top + cursorHeight);
410     }
411 }
412 
HandleDragRelease(const wxMouseEvent & event,AudacityProject * project)413 bool TextEditHelper::HandleDragRelease(const wxMouseEvent& event, AudacityProject* project)
414 {
415     if (event.Dragging())
416     {
417         if (!mRightDragging)
418         {
419             mCurrentCursorPos = FindCursorIndex(event.GetPosition());
420             return true;
421         }
422     }
423     else if (event.RightUp() && mBBox.Contains(event.GetPosition()))
424     {
425         auto delegate = mDelegate.lock();
426         if (delegate)
427         {
428             // popup menu for editing
429             // TODO: handle context menus via CellularPanel?
430             delegate->OnTextContextMenu(project, event.GetPosition());
431             return true;
432         }
433     }
434     return false;
435 }
436 
RemoveSelectedText(AudacityProject * project)437 void TextEditHelper::RemoveSelectedText(AudacityProject* project)
438 {
439     auto delegate = mDelegate.lock();
440     if (!delegate)
441         return;
442 
443     wxString left, right;
444 
445     int init = mInitialCursorPos;
446     int cur = mCurrentCursorPos;
447     if (init > cur)
448         std::swap(init, cur);
449 
450     if (init > 0)
451         left = mText.Left(init);
452 
453     if (cur < (int)mText.length())
454         right = mText.Mid(cur);
455 
456     mText = left + right;
457 
458     delegate->OnTextModified(project, mText);
459 
460     mInitialCursorPos = mCurrentCursorPos = left.length();
461 }
462 
FindCursorIndex(const wxPoint & point)463 int TextEditHelper::FindCursorIndex(const wxPoint& point)
464 {
465     int result = -1;
466     wxMemoryDC dc;
467     if (mFont.Ok())
468         dc.SetFont(mFont);
469 
470     // A bool indicator to see if set the cursor position or not
471     bool finished = false;
472     int charIndex = 1;
473     int partWidth;
474     int oneWidth;
475     //double bound;
476     wxString subString;
477 
478     auto offsetX = 0;
479     if (mOffset > 0)
480         offsetX = dc.GetTextExtent(mText.Left(mOffset)).GetWidth();
481 
482     const auto layout = wxTheApp->GetLayoutDirection();
483 
484     const int length = mText.length();
485     while (!finished && (charIndex < length + 1))
486     {
487         int unichar = (int)mText.at(charIndex - 1);
488         if ((0xDC00 <= unichar) && (unichar <= 0xDFFF)) {
489             charIndex++;
490             continue;
491         }
492         subString = mText.Left(charIndex);
493         // Get the width of substring
494         dc.GetTextExtent(subString, &partWidth, NULL);
495 
496         // Get the width of the last character
497         dc.GetTextExtent(subString.Right(1), &oneWidth, NULL);
498 
499         if (layout == wxLayout_RightToLeft)
500         {
501             auto bound = mBBox.GetRight() - partWidth + offsetX + oneWidth / 2;
502             if (point.x >= bound)
503             {
504                 result = charIndex - 1;
505                 finished = true;
506             }
507         }
508         else
509         {
510             auto bound = mBBox.GetLeft() + partWidth - offsetX - oneWidth / 2;
511             if (point.x <= bound)
512             {
513                 result = charIndex - 1;
514                 finished = true;
515             }
516         }
517         if (!finished)
518             ++charIndex;
519         else
520             break;
521     }
522     if (!finished)
523         // Cursor should be in the last position
524         result = length;
525 
526     return result;
527 }
528 
GetCharPositionX(int index,int * outX)529 bool TextEditHelper::GetCharPositionX(int index, int* outX)
530 {
531     if (!mFont.Ok())
532         return false;
533 
534     wxMemoryDC dc;
535     dc.SetFont(mFont);
536 
537     int offsetX{ 0 };
538     if (mOffset > 0)
539     {
540         offsetX = dc.GetTextExtent(mText.Left(mOffset)).GetWidth();
541     }
542 
543     if (wxTheApp->GetLayoutDirection() == wxLayout_RightToLeft)
544     {
545         if (index <= 0)
546             *outX = mBBox.GetRight() + offsetX;
547         else
548             *outX = mBBox.GetRight() - dc.GetTextExtent(mText.Left(index)).GetWidth() + offsetX;
549     }
550     else
551     {
552         if (index <= 0)
553             *outX = mBBox.GetLeft() - offsetX;
554         else
555             *outX = mBBox.GetLeft() + dc.GetTextExtent(mText.Left(index)).GetWidth() - offsetX;
556     }
557 
558     return true;
559 }
560 
GetBBox() const561 const wxRect& TextEditHelper::GetBBox() const
562 {
563     return mBBox;
564 }
565 
566 /// Cut the selected text in the text box
567 ///  @return true if text is selected in text box, false otherwise
CutSelectedText(AudacityProject & project)568 bool TextEditHelper::CutSelectedText(AudacityProject& project)
569 {
570     auto delegate = mDelegate.lock();
571     if (!delegate)
572         return false;
573 
574     if (mCurrentCursorPos == mInitialCursorPos)
575         return false;
576 
577     int init = mInitialCursorPos;
578     int cur = mCurrentCursorPos;
579     if (init > cur)
580         std::swap(init, cur);
581 
582     wxString left, right;
583     // data for cutting
584     wxString data = mText.Mid(init, cur - init);
585 
586     // get left-remaining text
587     if (init > 0)
588         left = mText.Left(init);
589 
590     // get right-remaining text
591     if (cur < (int)mText.length())
592         right = mText.Mid(cur);
593 
594     // set title to the combination of the two remainders
595     mText = left + right;
596 
597     delegate->OnTextModified(&project, mText);
598     // copy data onto clipboard
599     if (wxTheClipboard->Open()) {
600         // Clipboard owns the data you give it
601         wxTheClipboard->SetData(safenew wxTextDataObject(data));
602         wxTheClipboard->Close();
603     }
604 
605     // set cursor positions
606     mInitialCursorPos = mCurrentCursorPos = left.length();
607 
608     return true;
609 }
610 
611 /// Copy the selected text in the text box
612 ///  @return true if text is selected in text box, false otherwise
CopySelectedText(AudacityProject & project)613 bool TextEditHelper::CopySelectedText(AudacityProject& project)
614 {
615     if (mCurrentCursorPos == mInitialCursorPos)
616         return false;
617 
618     int init = mInitialCursorPos;
619     int cur = mCurrentCursorPos;
620     if (init > cur)
621         std::swap(init, cur);
622 
623     if (init == cur)
624         return false;
625 
626     // data for copying
627     wxString data = mText.Mid(init, cur - init);
628 
629     // copy the data on clipboard
630     if (wxTheClipboard->Open()) {
631         // Clipboard owns the data you give it
632         wxTheClipboard->SetData(safenew wxTextDataObject(data));
633         wxTheClipboard->Close();
634     }
635 
636     return true;
637 }
638 
639 // PRL:  should this set other fields of the label selection?
640 /// Paste the text on the clipboard to text box
641 ///  @return true if mouse is clicked in text box, false otherwise
PasteSelectedText(AudacityProject & project)642 bool TextEditHelper::PasteSelectedText(AudacityProject& project)
643 {
644     auto delegate = mDelegate.lock();
645     if (!delegate)
646         return false;
647 
648     wxString text, left, right;
649 
650     // if text data is available
651     if (wxTheClipboard->IsSupported(wxDF_UNICODETEXT))
652     {
653         if (wxTheClipboard->Open()) {
654             wxTextDataObject data;
655             wxTheClipboard->GetData(data);
656             wxTheClipboard->Close();
657             text = data.GetText();
658         }
659 
660         // Convert control characters to blanks
661         for (int i = 0; i < (int)text.length(); i++) {
662             if (wxIscntrl(text[i])) {
663                 text[i] = wxT(' ');
664             }
665         }
666     }
667 
668     int cur = mCurrentCursorPos, init = mInitialCursorPos;
669     if (init > cur)
670         std::swap(init, cur);
671 
672     left = mText.Left(init);
673     if (cur < (int)mText.length())
674         right = mText.Mid(cur);
675 
676     mText = left + text + right;
677 
678     delegate->OnTextModified(&project, mText);
679 
680     mInitialCursorPos = mCurrentCursorPos = left.length() + text.length();
681 
682     return true;
683 }
684